From a1d93f7a8e053f46d8768fe4e6962ba40fd9a8fa Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 18 Feb 2026 05:52:20 +0100 Subject: [PATCH] feat: implement MVP backend API Go backend with Gin, pgx, Valkey (go-valkey), and PostGIS. Domains: - Market search with PostGIS geo-queries (ST_DWithin, ST_Distance), German full-text search (tsvector + ILIKE fallback for compound words), date range filtering, pagination, and slug-based detail endpoint - Auth with email+password (bcrypt), JWT access tokens (15min), session tokens (30d, dual Valkey+Postgres storage), OAuth (Google/GitHub/Facebook), magic links, and TOTP 2FA - User profile with CRUD, soft-delete (30d grace), and restore Infrastructure: - 6 database migrations (users, sessions, oauth_accounts, magic_links, markets with PostGIS+FTS, totp_secrets) - Middleware: recovery, request ID, structured logging (slog), CORS, per-IP rate limiting, JWT auth - Seed data: 10 medieval markets across DACH region - Docker Compose (PostGIS 17 + Valkey 8), multi-stage Dockerfile, Woodpecker CI pipeline, Kubernetes manifests - Justfile, golangci-lint config, env example --- backend/.env.example | 49 +++ backend/.gitignore | 42 +++ backend/.golangci.yml | 60 +++ backend/.woodpecker.yml | 37 ++ backend/Justfile | 60 +++ backend/cmd/api/main.go | 75 ++++ backend/cmd/seed/main.go | 214 +++++++++++ backend/deploy/Dockerfile | 29 ++ backend/deploy/docker-compose.yml | 32 ++ backend/deploy/k8s/deployment.yml | 44 +++ backend/deploy/k8s/ingress.yml | 23 ++ backend/deploy/k8s/namespace.yml | 4 + backend/deploy/k8s/service.yml | 13 + backend/go.mod | 52 +++ backend/go.sum | 115 ++++++ backend/internal/config/config.go | 256 +++++++++++++ backend/internal/database/postgres.go | 34 ++ backend/internal/database/valkey.go | 37 ++ backend/internal/domain/auth/dto.go | 48 +++ backend/internal/domain/auth/handler.go | 187 ++++++++++ backend/internal/domain/auth/magiclink.go | 140 +++++++ backend/internal/domain/auth/model.go | 47 +++ backend/internal/domain/auth/oauth.go | 345 ++++++++++++++++++ backend/internal/domain/auth/repository.go | 266 ++++++++++++++ backend/internal/domain/auth/routes.go | 18 + backend/internal/domain/auth/service.go | 160 ++++++++ backend/internal/domain/auth/token.go | 84 +++++ backend/internal/domain/auth/totp.go | 72 ++++ backend/internal/domain/market/dto.go | 149 ++++++++ backend/internal/domain/market/handler.go | 76 ++++ backend/internal/domain/market/model.go | 47 +++ backend/internal/domain/market/repository.go | 199 ++++++++++ backend/internal/domain/market/routes.go | 11 + backend/internal/domain/market/service.go | 26 ++ backend/internal/domain/user/dto.go | 34 ++ backend/internal/domain/user/handler.go | 96 +++++ backend/internal/domain/user/model.go | 24 ++ backend/internal/domain/user/repository.go | 186 ++++++++++ backend/internal/domain/user/routes.go | 13 + backend/internal/domain/user/service.go | 60 +++ backend/internal/middleware/auth.go | 61 ++++ backend/internal/middleware/cors.go | 38 ++ backend/internal/middleware/logging.go | 51 +++ backend/internal/middleware/ratelimit.go | 62 ++++ backend/internal/middleware/recovery.go | 31 ++ backend/internal/middleware/requestid.go | 20 + backend/internal/pkg/apierror/error.go | 82 +++++ backend/internal/pkg/pagination/pagination.go | 75 ++++ backend/internal/pkg/validate/validate.go | 51 +++ backend/internal/server/routes.go | 73 ++++ backend/internal/server/server.go | 71 ++++ .../migrations/000001_create_users.down.sql | 3 + backend/migrations/000001_create_users.up.sql | 30 ++ .../000002_create_sessions.down.sql | 1 + .../migrations/000002_create_sessions.up.sql | 12 + .../000003_create_oauth_accounts.down.sql | 2 + .../000003_create_oauth_accounts.up.sql | 20 + .../000004_create_magic_links.down.sql | 1 + .../000004_create_magic_links.up.sql | 11 + .../migrations/000005_create_markets.down.sql | 5 + .../migrations/000005_create_markets.up.sql | 63 ++++ .../000006_create_totp_secrets.down.sql | 1 + .../000006_create_totp_secrets.up.sql | 9 + 63 files changed, 4237 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/.golangci.yml create mode 100644 backend/.woodpecker.yml create mode 100644 backend/Justfile create mode 100644 backend/cmd/api/main.go create mode 100644 backend/cmd/seed/main.go create mode 100644 backend/deploy/Dockerfile create mode 100644 backend/deploy/docker-compose.yml create mode 100644 backend/deploy/k8s/deployment.yml create mode 100644 backend/deploy/k8s/ingress.yml create mode 100644 backend/deploy/k8s/namespace.yml create mode 100644 backend/deploy/k8s/service.yml create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/database/postgres.go create mode 100644 backend/internal/database/valkey.go create mode 100644 backend/internal/domain/auth/dto.go create mode 100644 backend/internal/domain/auth/handler.go create mode 100644 backend/internal/domain/auth/magiclink.go create mode 100644 backend/internal/domain/auth/model.go create mode 100644 backend/internal/domain/auth/oauth.go create mode 100644 backend/internal/domain/auth/repository.go create mode 100644 backend/internal/domain/auth/routes.go create mode 100644 backend/internal/domain/auth/service.go create mode 100644 backend/internal/domain/auth/token.go create mode 100644 backend/internal/domain/auth/totp.go create mode 100644 backend/internal/domain/market/dto.go create mode 100644 backend/internal/domain/market/handler.go create mode 100644 backend/internal/domain/market/model.go create mode 100644 backend/internal/domain/market/repository.go create mode 100644 backend/internal/domain/market/routes.go create mode 100644 backend/internal/domain/market/service.go create mode 100644 backend/internal/domain/user/dto.go create mode 100644 backend/internal/domain/user/handler.go create mode 100644 backend/internal/domain/user/model.go create mode 100644 backend/internal/domain/user/repository.go create mode 100644 backend/internal/domain/user/routes.go create mode 100644 backend/internal/domain/user/service.go create mode 100644 backend/internal/middleware/auth.go create mode 100644 backend/internal/middleware/cors.go create mode 100644 backend/internal/middleware/logging.go create mode 100644 backend/internal/middleware/ratelimit.go create mode 100644 backend/internal/middleware/recovery.go create mode 100644 backend/internal/middleware/requestid.go create mode 100644 backend/internal/pkg/apierror/error.go create mode 100644 backend/internal/pkg/pagination/pagination.go create mode 100644 backend/internal/pkg/validate/validate.go create mode 100644 backend/internal/server/routes.go create mode 100644 backend/internal/server/server.go create mode 100644 backend/migrations/000001_create_users.down.sql create mode 100644 backend/migrations/000001_create_users.up.sql create mode 100644 backend/migrations/000002_create_sessions.down.sql create mode 100644 backend/migrations/000002_create_sessions.up.sql create mode 100644 backend/migrations/000003_create_oauth_accounts.down.sql create mode 100644 backend/migrations/000003_create_oauth_accounts.up.sql create mode 100644 backend/migrations/000004_create_magic_links.down.sql create mode 100644 backend/migrations/000004_create_magic_links.up.sql create mode 100644 backend/migrations/000005_create_markets.down.sql create mode 100644 backend/migrations/000005_create_markets.up.sql create mode 100644 backend/migrations/000006_create_totp_secrets.down.sql create mode 100644 backend/migrations/000006_create_totp_secrets.up.sql diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..eac1389 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,49 @@ +# Server +APP_ENV=development +APP_PORT=8080 +APP_HOST=0.0.0.0 + +# PostgreSQL +DB_HOST=localhost +DB_PORT=5432 +DB_USER=marktvogt +DB_PASSWORD=marktvogt +DB_NAME=marktvogt +DB_SSLMODE=disable +DB_MAX_CONNS=25 +DB_MIN_CONNS=5 + +# Valkey (Redis-compatible) +VALKEY_ADDR=localhost:6379 +VALKEY_PASSWORD= +VALKEY_DB=0 + +# JWT +JWT_SECRET=change-me-in-production +JWT_ACCESS_TTL=15m +JWT_SESSION_TTL=720h + +# CORS +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Rate Limiting +RATE_LIMIT_RPS=10 +RATE_LIMIT_BURST=20 + +# Sentry +SENTRY_DSN= + +# OAuth (configure per provider) +OAUTH_GOOGLE_CLIENT_ID= +OAUTH_GOOGLE_CLIENT_SECRET= +OAUTH_APPLE_CLIENT_ID= +OAUTH_APPLE_CLIENT_SECRET= +OAUTH_FACEBOOK_CLIENT_ID= +OAUTH_FACEBOOK_CLIENT_SECRET= +OAUTH_GITHUB_CLIENT_ID= +OAUTH_GITHUB_CLIENT_SECRET= +OAUTH_REDIRECT_BASE_URL=http://localhost:8080 + +# Magic Link +MAGIC_LINK_TTL=15m +MAGIC_LINK_BASE_URL=http://localhost:5173/auth/magic-link/verify diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..9afcad4 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,42 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +/backend +/api + +# Test binary +*.test + +# Coverage +*.out +coverage.html + +# Go workspace +go.work +go.work.sum + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Vendor (we use modules) +vendor/ + +# Build output +/dist/ +/tmp/ diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..29dc9a4 --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,60 @@ +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - bodyclose + - copyloopvar + - dupl + - errname + - errorlint + - exhaustive + - goconst + - gocritic + - gofmt + - goimports + - misspell + - nilerr + - noctx + - prealloc + - predeclared + - revive + - sqlclosecheck + - unconvert + - unparam + - whitespace + +linters-settings: + govet: + shadow: true + goconst: + min-len: 3 + min-occurrences: 3 + gocritic: + enabled-tags: + - diagnostic + - performance + - style + revive: + rules: + - name: exported + arguments: + - disableStutteringCheck + exhaustive: + default-signifies-exhaustive: true + +issues: + exclude-rules: + - path: _test\.go + linters: + - dupl + - goconst + - errcheck + max-issues-per-linter: 0 + max-same-issues: 0 + +run: + timeout: 5m diff --git a/backend/.woodpecker.yml b/backend/.woodpecker.yml new file mode 100644 index 0000000..6a5748b --- /dev/null +++ b/backend/.woodpecker.yml @@ -0,0 +1,37 @@ +when: + - event: [push, pull_request] + +steps: + lint: + image: golangci/golangci-lint:v2.1 + commands: + - golangci-lint run ./... + + test: + image: golang:1.25-alpine + commands: + - apk add --no-cache git + - go test ./... -v -count=1 -race + + build: + image: golang:1.25-alpine + commands: + - apk add --no-cache git + - CGO_ENABLED=0 go build -ldflags="-s -w" -o /dev/null ./cmd/api + + docker: + image: woodpeckerci/plugin-docker-buildx + settings: + repo: registry.itsh.dev/marktvogt/backend + tags: + - latest + - "${CI_COMMIT_SHA:0:8}" + dockerfile: deploy/Dockerfile + registry: registry.itsh.dev + username: + from_secret: registry_user + password: + from_secret: registry_password + when: + - event: push + branch: main diff --git a/backend/Justfile b/backend/Justfile new file mode 100644 index 0000000..ebd0938 --- /dev/null +++ b/backend/Justfile @@ -0,0 +1,60 @@ +set dotenv-load + +default: + @just --list + +# Start local dev infrastructure (Postgres + Valkey) +dev: + docker compose -f deploy/docker-compose.yml up -d + +# Stop local dev infrastructure +dev-down: + docker compose -f deploy/docker-compose.yml down + +# Run database migrations +migrate: + go run -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest \ + -path migrations \ + -database "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" \ + up + +# Rollback last migration +migrate-down: + go run -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest \ + -path migrations \ + -database "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" \ + down 1 + +# Seed sample market data +seed: + go run ./cmd/seed + +# Run the API server +run: + go run ./cmd/api + +# Run all tests +test: + go test ./... -v -count=1 + +# Run tests with coverage +test-cover: + go test ./... -v -count=1 -coverprofile=coverage.out + go tool cover -html=coverage.out -o coverage.html + +# Run linter +lint: + golangci-lint run ./... + +# Format code +fmt: + gofmt -w . + goimports -w . + +# Build the API binary +build: + go build -o api ./cmd/api + +# Clean build artifacts +clean: + rm -f api coverage.out coverage.html diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..fc2e81b --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "marktvogt.de/backend/internal/config" + "marktvogt.de/backend/internal/database" + "marktvogt.de/backend/internal/server" +) + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + 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 + } + + if cfg.IsDev() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + db, err := database.NewPostgres(ctx, cfg.DB) + if err != nil { + return err + } + defer db.Close() + + vk, err := database.NewValkey(cfg.Valkey) + if err != nil { + return err + } + defer vk.Close() + + srv := server.New(cfg, db, vk) + + errCh := make(chan error, 1) + go func() { + errCh <- srv.Start() + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-errCh: + return err + case sig := <-quit: + slog.Info("received signal, shutting down", "signal", sig) + } + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + return srv.Shutdown(shutdownCtx) +} diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..ef84c3b --- /dev/null +++ b/backend/cmd/seed/main.go @@ -0,0 +1,214 @@ +package main + +import ( + "context" + "log/slog" + "os" + "time" + + "marktvogt.de/backend/internal/config" + "marktvogt.de/backend/internal/database" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + if err := run(); err != nil { + slog.Error("seed failed", "error", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + db, err := database.NewPostgres(ctx, cfg.DB) + if err != nil { + return err + } + defer db.Close() + + slog.Info("seeding market data...") + + markets := []struct { + Slug string + Name string + Description string + Lat, Lon float64 + Street string + City string + State string + Zip string + StartDate string + EndDate string + OpeningHours string + AdmissionInfo string + Website string + OrganizerName string + }{ + { + Slug: "weihnachtsmarkt-dortmund-2026", + Name: "Dortmunder Weihnachtsmarkt", + Description: "Einer der groessten und aeltesten Weihnachtsmaerkte Deutschlands im Herzen von Dortmund. Mit dem groessten Weihnachtsbaum der Welt.", + Lat: 51.5136, Lon: 7.4653, + Street: "Hansaplatz", City: "Dortmund", State: "NRW", Zip: "44137", + StartDate: "2026-11-19", EndDate: "2026-12-30", + OpeningHours: `[{"day":"Mo-Do","open":"10:00","close":"21:00"},{"day":"Fr-Sa","open":"10:00","close":"22:00"},{"day":"So","open":"12:00","close":"21:00"}]`, + AdmissionInfo: `{"adult_cents":0,"child_cents":0,"notes":"Eintritt frei"}`, + Website: "https://www.dortmunderweihnachtsmarkt.de", + OrganizerName: "Stadt Dortmund", + }, + { + Slug: "mittelaltermarkt-soest-2026", + Name: "Mittelalterlicher Markt zu Soest", + Description: "Historischer Mittelaltermarkt in der Altstadt von Soest. Handwerker, Gauckler, Met und Spanferkel in mittelalterlichem Ambiente.", + Lat: 51.5711, Lon: 8.1092, + Street: "Marktplatz", City: "Soest", State: "NRW", Zip: "59494", + StartDate: "2026-06-12", EndDate: "2026-06-14", + OpeningHours: `[{"day":"Fr","open":"16:00","close":"23:00"},{"day":"Sa","open":"10:00","close":"23:00"},{"day":"So","open":"10:00","close":"20:00"}]`, + AdmissionInfo: `{"adult_cents":800,"child_cents":400,"reduced_cents":600,"free_under_age":6,"notes":"Kinder unter 6 frei"}`, + Website: "https://www.mittelaltermarkt-soest.de", + OrganizerName: "Mittelalterverein Soest e.V.", + }, + { + Slug: "ritterfest-muenchen-2026", + Name: "Muenchner Ritterfest", + Description: "Grosses Ritterfest im Englischen Garten mit Turnieren, Schaukampf, Handwerk und mittelalterlicher Musik.", + Lat: 48.1644, Lon: 11.6053, + Street: "Englischer Garten", City: "Muenchen", State: "Bayern", Zip: "80538", + StartDate: "2026-07-03", EndDate: "2026-07-05", + OpeningHours: `[{"day":"Fr","open":"15:00","close":"22:00"},{"day":"Sa","open":"10:00","close":"22:00"},{"day":"So","open":"10:00","close":"19:00"}]`, + AdmissionInfo: `{"adult_cents":1200,"child_cents":600,"reduced_cents":900,"free_under_age":4,"notes":"Familienkarte 30 EUR"}`, + Website: "", + OrganizerName: "Ritterschaft Muenchen", + }, + { + Slug: "spectaculum-hamburg-2026", + Name: "Hamburger Mittelalter Spectaculum", + Description: "Mittelalterliches Spectaculum im Stadtpark Hamburg. Fahrendes Volk, Feuershow, Handwerk und Tavernenmusik.", + Lat: 53.5901, Lon: 10.0180, + Street: "Stadtpark", City: "Hamburg", State: "Hamburg", Zip: "22303", + StartDate: "2026-08-14", EndDate: "2026-08-16", + OpeningHours: `[{"day":"Fr","open":"14:00","close":"23:00"},{"day":"Sa","open":"10:00","close":"23:00"},{"day":"So","open":"10:00","close":"20:00"}]`, + AdmissionInfo: `{"adult_cents":1000,"child_cents":500,"reduced_cents":800,"free_under_age":5,"notes":""}`, + Website: "", + OrganizerName: "Spectaculum Events GmbH", + }, + { + Slug: "ritterturnier-kaltenberg-2026", + Name: "Kaltenberger Ritterturnier", + Description: "Das groesste Ritterturnier der Welt auf Schloss Kaltenberg. Spektakulaere Turniere, historisches Lagerleben und Mittelaltermarkt.", + Lat: 48.1275, Lon: 11.0239, + Street: "Schloss Kaltenberg", City: "Geltendorf", State: "Bayern", Zip: "82269", + StartDate: "2026-07-10", EndDate: "2026-07-26", + OpeningHours: `[{"day":"Fr","open":"17:00","close":"23:00"},{"day":"Sa","open":"11:00","close":"23:00"},{"day":"So","open":"11:00","close":"20:00"}]`, + AdmissionInfo: `{"adult_cents":2500,"child_cents":1200,"reduced_cents":1900,"free_under_age":4,"notes":"Turnierticket separat"}`, + Website: "https://www.ritterturnier.de", + OrganizerName: "Kaltenberg Ritterturnier GmbH", + }, + { + Slug: "mittelaltermarkt-koeln-2026", + Name: "Koelner Mittelaltermarkt am Schokoladenmuseum", + Description: "Mittelaltermarkt direkt am Rheinufer neben dem Schokoladenmuseum. Handwerker, Gauckler und Musikanten.", + Lat: 50.9324, Lon: 6.9643, + Street: "Am Schokoladenmuseum", City: "Koeln", State: "NRW", Zip: "50678", + StartDate: "2026-05-15", EndDate: "2026-05-17", + OpeningHours: `[{"day":"Fr","open":"15:00","close":"22:00"},{"day":"Sa","open":"10:00","close":"22:00"},{"day":"So","open":"10:00","close":"19:00"}]`, + AdmissionInfo: `{"adult_cents":900,"child_cents":500,"reduced_cents":700,"free_under_age":6,"notes":""}`, + Website: "", + OrganizerName: "Mittelalter Events Koeln", + }, + { + Slug: "mittelaltermarkt-nuernberg-2026", + Name: "Nuernberger Mittelalterfest", + Description: "Historisches Mittelalterfest in der Nuernberger Altstadt mit Handwerksvorfuehrungen, Musik und Gaukelei.", + Lat: 49.4539, Lon: 11.0775, + Street: "Hauptmarkt", City: "Nuernberg", State: "Bayern", Zip: "90403", + StartDate: "2026-06-19", EndDate: "2026-06-21", + OpeningHours: `[{"day":"Fr","open":"16:00","close":"22:00"},{"day":"Sa","open":"10:00","close":"22:00"},{"day":"So","open":"10:00","close":"19:00"}]`, + AdmissionInfo: `{"adult_cents":700,"child_cents":0,"reduced_cents":500,"free_under_age":12,"notes":"Kinder unter 12 frei"}`, + Website: "", + OrganizerName: "Stadt Nuernberg Kulturamt", + }, + { + Slug: "mittelaltermarkt-wien-2026", + Name: "Wiener Mittelaltermarkt am Hof", + Description: "Mittelaltermarkt im Herzen Wiens am historischen Platz Am Hof. Ritter, Handwerker und mittelalterliche Kueche.", + Lat: 48.2116, Lon: 16.3670, + Street: "Am Hof", City: "Wien", State: "Wien", Zip: "1010", + StartDate: "2026-09-04", EndDate: "2026-09-06", + OpeningHours: `[{"day":"Fr","open":"14:00","close":"22:00"},{"day":"Sa","open":"10:00","close":"22:00"},{"day":"So","open":"10:00","close":"19:00"}]`, + AdmissionInfo: `{"adult_cents":1100,"child_cents":500,"reduced_cents":800,"free_under_age":6,"notes":""}`, + Website: "", + OrganizerName: "Mittelalterverein Wien", + }, + { + Slug: "mittelaltermarkt-bern-2026", + Name: "Berner Mittelaltermarkt", + Description: "Mittelaltermarkt in der Altstadt von Bern. Schweizer Handwerkskunst, Met, Ritterspiele und historisches Treiben.", + Lat: 46.9480, Lon: 7.4474, + Street: "Muensterplatz", City: "Bern", State: "Bern", Zip: "3011", + StartDate: "2026-05-29", EndDate: "2026-05-31", + OpeningHours: `[{"day":"Fr","open":"15:00","close":"22:00"},{"day":"Sa","open":"10:00","close":"22:00"},{"day":"So","open":"10:00","close":"18:00"}]`, + AdmissionInfo: `{"adult_cents":1500,"child_cents":700,"reduced_cents":1100,"free_under_age":6,"notes":"Preise in CHF-Rappen"}`, + Website: "", + OrganizerName: "Verein Mittelalter Bern", + }, + { + Slug: "spectaculum-worms-2026", + Name: "Spectaculum Worms", + Description: "Grosses Mittelalter Spectaculum in der Nibelungenstadt Worms. Ritterlager, Handwerk, Feuershow und historische Musik.", + Lat: 49.6341, Lon: 8.3507, + Street: "Wormser Wäldchen", City: "Worms", State: "Rheinland-Pfalz", Zip: "67547", + StartDate: "2026-08-21", EndDate: "2026-08-23", + OpeningHours: `[{"day":"Fr","open":"15:00","close":"23:00"},{"day":"Sa","open":"10:00","close":"23:00"},{"day":"So","open":"10:00","close":"20:00"}]`, + AdmissionInfo: `{"adult_cents":1100,"child_cents":600,"reduced_cents":900,"free_under_age":5,"notes":""}`, + Website: "", + OrganizerName: "Spectaculum Events GmbH", + }, + } + + for _, m := range markets { + _, err := db.Exec(ctx, ` + INSERT INTO markets (slug, name, description, location, street, city, state, zip, country, + start_date, end_date, opening_hours, admission_info, website, organizer_name) + VALUES ($1, $2, $3, ST_SetSRID(ST_MakePoint($4, $5), 4326)::geography, + $6, $7, $8, $9, 'DE', $10::date, $11::date, $12::jsonb, $13::jsonb, $14, $15) + ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + location = EXCLUDED.location, + street = EXCLUDED.street, + city = EXCLUDED.city, + state = EXCLUDED.state, + zip = EXCLUDED.zip, + start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + opening_hours = EXCLUDED.opening_hours, + admission_info = EXCLUDED.admission_info, + website = EXCLUDED.website, + organizer_name = EXCLUDED.organizer_name + `, + m.Slug, m.Name, m.Description, m.Lon, m.Lat, + m.Street, m.City, m.State, m.Zip, + m.StartDate, m.EndDate, m.OpeningHours, m.AdmissionInfo, + m.Website, m.OrganizerName, + ) + if err != nil { + slog.Error("failed to seed market", "slug", m.Slug, "error", err) + continue + } + slog.Info("seeded market", "slug", m.Slug, "city", m.City) + } + + slog.Info("seed complete") + return nil +} diff --git a/backend/deploy/Dockerfile b/backend/deploy/Dockerfile new file mode 100644 index 0000000..df97730 --- /dev/null +++ b/backend/deploy/Dockerfile @@ -0,0 +1,29 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /api ./cmd/api + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata + +RUN adduser -D -g '' appuser + +WORKDIR /app + +COPY --from=builder /api . +COPY migrations/ ./migrations/ + +USER appuser + +EXPOSE 8080 + +ENTRYPOINT ["./api"] diff --git a/backend/deploy/docker-compose.yml b/backend/deploy/docker-compose.yml new file mode 100644 index 0000000..119bac6 --- /dev/null +++ b/backend/deploy/docker-compose.yml @@ -0,0 +1,32 @@ +services: + postgres: + image: postgis/postgis:17-3.5 + ports: + - "5432:5432" + environment: + POSTGRES_USER: marktvogt + POSTGRES_PASSWORD: marktvogt + POSTGRES_DB: marktvogt + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U marktvogt"] + interval: 5s + timeout: 5s + retries: 5 + + valkey: + image: valkey/valkey:8-alpine + ports: + - "6379:6379" + volumes: + - vkdata:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: + vkdata: diff --git a/backend/deploy/k8s/deployment.yml b/backend/deploy/k8s/deployment.yml new file mode 100644 index 0000000..9d545aa --- /dev/null +++ b/backend/deploy/k8s/deployment.yml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: marktvogt + labels: + app: backend +spec: + replicas: 2 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: registry.itsh.dev/marktvogt/backend:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: backend-env + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/backend/deploy/k8s/ingress.yml b/backend/deploy/k8s/ingress.yml new file mode 100644 index 0000000..f935b0d --- /dev/null +++ b/backend/deploy/k8s/ingress.yml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: backend + namespace: marktvogt + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - api.marktvogt.de + secretName: backend-tls + rules: + - host: api.marktvogt.de + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: backend + port: + number: 80 diff --git a/backend/deploy/k8s/namespace.yml b/backend/deploy/k8s/namespace.yml new file mode 100644 index 0000000..b718560 --- /dev/null +++ b/backend/deploy/k8s/namespace.yml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: marktvogt diff --git a/backend/deploy/k8s/service.yml b/backend/deploy/k8s/service.yml new file mode 100644 index 0000000..1ae3b20 --- /dev/null +++ b/backend/deploy/k8s/service.yml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: marktvogt +spec: + selector: + app: backend + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: ClusterIP diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..4eb7c9d --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,52 @@ +module marktvogt.de/backend + +go 1.25.7 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/go-playground/validator/v10 v10.30.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/pquerna/otp v1.5.0 + github.com/valkey-io/valkey-go v1.0.72 + golang.org/x/crypto v0.48.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/time v0.14.0 +) + +require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // 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/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..0621191 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,115 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/valkey-io/valkey-go v1.0.72 h1:iRWt1hJyOchcEgbHSkRY3aKkcBudxvMaVMsmxuYxuxE= +github.com/valkey-io/valkey-go v1.0.72/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..2d30c22 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,256 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +type Config struct { + App AppConfig + DB DBConfig + Valkey ValkeyConfig + JWT JWTConfig + CORS CORSConfig + Rate RateConfig + Sentry SentryConfig + OAuth OAuthConfig + Magic MagicLinkConfig +} + +type AppConfig struct { + Env string + Host string + Port int +} + +type DBConfig struct { + Host string + Port int + User string + Password string + Name string + SSLMode string + MaxConns int + MinConns int +} + +func (c DBConfig) DSN() string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=%s", + c.User, c.Password, c.Host, c.Port, c.Name, c.SSLMode, + ) +} + +type ValkeyConfig struct { + Addr string + Password string + DB int +} + +type JWTConfig struct { + Secret string + AccessTTL time.Duration + SessionTTL time.Duration +} + +type CORSConfig struct { + Origins string +} + +type RateConfig struct { + RPS float64 + Burst int +} + +type SentryConfig struct { + DSN string +} + +type OAuthProviderConfig struct { + ClientID string + ClientSecret string +} + +type OAuthConfig struct { + Google OAuthProviderConfig + Apple OAuthProviderConfig + Facebook OAuthProviderConfig + GitHub OAuthProviderConfig + RedirectBaseURL string +} + +type MagicLinkConfig struct { + TTL time.Duration + BaseURL string +} + +func Load() (*Config, error) { + port, err := envInt("APP_PORT", 8080) + if err != nil { + return nil, fmt.Errorf("APP_PORT: %w", err) + } + + dbPort, err := envInt("DB_PORT", 5432) + if err != nil { + return nil, fmt.Errorf("DB_PORT: %w", err) + } + + dbMaxConns, err := envInt("DB_MAX_CONNS", 25) + if err != nil { + return nil, fmt.Errorf("DB_MAX_CONNS: %w", err) + } + + dbMinConns, err := envInt("DB_MIN_CONNS", 5) + if err != nil { + return nil, fmt.Errorf("DB_MIN_CONNS: %w", err) + } + + valkeyDB, err := envInt("VALKEY_DB", 0) + if err != nil { + return nil, fmt.Errorf("VALKEY_DB: %w", err) + } + + accessTTL, err := envDuration("JWT_ACCESS_TTL", 15*time.Minute) + if err != nil { + return nil, fmt.Errorf("JWT_ACCESS_TTL: %w", err) + } + + sessionTTL, err := envDuration("JWT_SESSION_TTL", 720*time.Hour) + if err != nil { + return nil, fmt.Errorf("JWT_SESSION_TTL: %w", err) + } + + rps, err := envFloat("RATE_LIMIT_RPS", 10) + if err != nil { + return nil, fmt.Errorf("RATE_LIMIT_RPS: %w", err) + } + + burst, err := envInt("RATE_LIMIT_BURST", 20) + if err != nil { + return nil, fmt.Errorf("RATE_LIMIT_BURST: %w", err) + } + + magicTTL, err := envDuration("MAGIC_LINK_TTL", 15*time.Minute) + if err != nil { + return nil, fmt.Errorf("MAGIC_LINK_TTL: %w", err) + } + + jwtSecret := envStr("JWT_SECRET", "") + if jwtSecret == "" { + return nil, fmt.Errorf("JWT_SECRET is required") + } + + return &Config{ + App: AppConfig{ + Env: envStr("APP_ENV", "development"), + Host: envStr("APP_HOST", "0.0.0.0"), + Port: port, + }, + DB: DBConfig{ + Host: envStr("DB_HOST", "localhost"), + Port: dbPort, + User: envStr("DB_USER", "marktvogt"), + Password: envStr("DB_PASSWORD", "marktvogt"), + Name: envStr("DB_NAME", "marktvogt"), + SSLMode: envStr("DB_SSLMODE", "disable"), + MaxConns: dbMaxConns, + MinConns: dbMinConns, + }, + Valkey: ValkeyConfig{ + Addr: envStr("VALKEY_ADDR", "localhost:6379"), + Password: envStr("VALKEY_PASSWORD", ""), + DB: valkeyDB, + }, + JWT: JWTConfig{ + Secret: jwtSecret, + AccessTTL: accessTTL, + SessionTTL: sessionTTL, + }, + CORS: CORSConfig{ + Origins: envStr("CORS_ORIGINS", "http://localhost:5173"), + }, + Rate: RateConfig{ + RPS: rps, + Burst: burst, + }, + Sentry: SentryConfig{ + DSN: envStr("SENTRY_DSN", ""), + }, + OAuth: OAuthConfig{ + Google: OAuthProviderConfig{ + ClientID: envStr("OAUTH_GOOGLE_CLIENT_ID", ""), + ClientSecret: envStr("OAUTH_GOOGLE_CLIENT_SECRET", ""), + }, + Apple: OAuthProviderConfig{ + ClientID: envStr("OAUTH_APPLE_CLIENT_ID", ""), + ClientSecret: envStr("OAUTH_APPLE_CLIENT_SECRET", ""), + }, + Facebook: OAuthProviderConfig{ + ClientID: envStr("OAUTH_FACEBOOK_CLIENT_ID", ""), + ClientSecret: envStr("OAUTH_FACEBOOK_CLIENT_SECRET", ""), + }, + GitHub: OAuthProviderConfig{ + ClientID: envStr("OAUTH_GITHUB_CLIENT_ID", ""), + ClientSecret: envStr("OAUTH_GITHUB_CLIENT_SECRET", ""), + }, + RedirectBaseURL: envStr("OAUTH_REDIRECT_BASE_URL", "http://localhost:8080"), + }, + Magic: MagicLinkConfig{ + TTL: magicTTL, + BaseURL: envStr("MAGIC_LINK_BASE_URL", "http://localhost:5173/auth/magic-link/verify"), + }, + }, nil +} + +func (c *Config) Addr() string { + return fmt.Sprintf("%s:%d", c.App.Host, c.App.Port) +} + +func (c *Config) IsDev() bool { + return c.App.Env == "development" +} + +func envStr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) (int, error) { + v := os.Getenv(key) + if v == "" { + return fallback, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("invalid integer %q: %w", v, err) + } + return n, nil +} + +func envFloat(key string, fallback float64) (float64, error) { + v := os.Getenv(key) + if v == "" { + return fallback, nil + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return 0, fmt.Errorf("invalid float %q: %w", v, err) + } + return f, nil +} + +func envDuration(key string, fallback time.Duration) (time.Duration, error) { + v := os.Getenv(key) + if v == "" { + return fallback, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", v, err) + } + return d, nil +} diff --git a/backend/internal/database/postgres.go b/backend/internal/database/postgres.go new file mode 100644 index 0000000..966c1dc --- /dev/null +++ b/backend/internal/database/postgres.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "fmt" + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" + + "marktvogt.de/backend/internal/config" +) + +func NewPostgres(ctx context.Context, cfg config.DBConfig) (*pgxpool.Pool, error) { + poolCfg, err := pgxpool.ParseConfig(cfg.DSN()) + if err != nil { + return nil, fmt.Errorf("parsing postgres config: %w", err) + } + + poolCfg.MaxConns = int32(cfg.MaxConns) + poolCfg.MinConns = int32(cfg.MinConns) + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("creating postgres pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging postgres: %w", err) + } + + slog.Info("connected to postgres", "host", cfg.Host, "port", cfg.Port, "db", cfg.Name) + return pool, nil +} diff --git a/backend/internal/database/valkey.go b/backend/internal/database/valkey.go new file mode 100644 index 0000000..80eb4f3 --- /dev/null +++ b/backend/internal/database/valkey.go @@ -0,0 +1,37 @@ +package database + +import ( + "context" + "fmt" + "log/slog" + + "github.com/valkey-io/valkey-go" + + "marktvogt.de/backend/internal/config" +) + +func NewValkey(cfg config.ValkeyConfig) (valkey.Client, error) { + opts := valkey.ClientOption{ + InitAddress: []string{cfg.Addr}, + } + if cfg.Password != "" { + opts.Password = cfg.Password + } + if cfg.DB > 0 { + opts.SelectDB = cfg.DB + } + + client, err := valkey.NewClient(opts) + if err != nil { + return nil, fmt.Errorf("creating valkey client: %w", err) + } + + ctx := context.Background() + if err := client.Do(ctx, client.B().Ping().Build()).Error(); err != nil { + client.Close() + return nil, fmt.Errorf("pinging valkey: %w", err) + } + + slog.Info("connected to valkey", "addr", cfg.Addr) + return client, nil +} diff --git a/backend/internal/domain/auth/dto.go b/backend/internal/domain/auth/dto.go new file mode 100644 index 0000000..cc08972 --- /dev/null +++ b/backend/internal/domain/auth/dto.go @@ -0,0 +1,48 @@ +package auth + +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=128"` + DisplayName string `json:"display_name" validate:"required,min=1,max=100"` +} + +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` + TOTPCode string `json:"totp_code,omitempty"` +} + +type MagicLinkRequest struct { + Email string `json:"email" validate:"required,email"` +} + +type TOTPSetupResponse struct { + Data TOTPSetupData `json:"data"` +} + +type TOTPSetupData struct { + Secret string `json:"secret"` + URL string `json:"url"` +} + +type TOTPVerifyRequest struct { + Code string `json:"code" validate:"required,len=6"` +} + +type AuthResponse struct { + Data AuthData `json:"data"` +} + +type AuthData struct { + AccessToken string `json:"access_token"` + SessionToken string `json:"session_token"` + ExpiresIn int `json:"expires_in"` +} + +type MessageResponse struct { + Data MessageData `json:"data"` +} + +type MessageData struct { + Message string `json:"message"` +} diff --git a/backend/internal/domain/auth/handler.go b/backend/internal/domain/auth/handler.go new file mode 100644 index 0000000..70fe80f --- /dev/null +++ b/backend/internal/domain/auth/handler.go @@ -0,0 +1,187 @@ +package auth + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "marktvogt.de/backend/internal/domain/user" + "marktvogt.de/backend/internal/pkg/apierror" + "marktvogt.de/backend/internal/pkg/validate" +) + +type Handler struct { + service *Service + userRepo user.Repository +} + +func NewHandler(service *Service, userRepo user.Repository) *Handler { + return &Handler{service: service, userRepo: userRepo} +} + +func (h *Handler) Register(c *gin.Context) { + var req RegisterRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + data, err := h.service.Register(c.Request.Context(), req, c.ClientIP(), c.GetHeader("User-Agent")) + if err != nil { + if errors.Is(err, user.ErrEmailAlreadyTaken) { + apiErr := apierror.Conflict("email already registered") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + apiErr := apierror.Internal("registration failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusCreated, AuthResponse{Data: data}) +} + +func (h *Handler) Login(c *gin.Context) { + var req LoginRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + data, err := h.service.Login(c.Request.Context(), req, c.ClientIP(), c.GetHeader("User-Agent")) + if err != nil { + msg := err.Error() + if msg == "invalid credentials" { + apiErr := apierror.Unauthorized("invalid email or password") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + if msg == "2fa_required" { + apiErr := apierror.BadRequest("2fa_required", "2FA code required") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + apiErr := apierror.Internal("login failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, AuthResponse{Data: data}) +} + +func (h *Handler) Logout(c *gin.Context) { + sessionToken := extractSessionToken(c) + if sessionToken == "" { + apiErr := apierror.Unauthorized("session token required") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + tokenHash := HashToken(sessionToken) + if err := h.service.Logout(c.Request.Context(), tokenHash); err != nil { + apiErr := apierror.Internal("logout failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "logged out"}}) +} + +func (h *Handler) Refresh(c *gin.Context) { + sessionToken := extractSessionToken(c) + if sessionToken == "" { + apiErr := apierror.Unauthorized("session token required") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + data, err := h.service.RefreshToken(c.Request.Context(), sessionToken, c.ClientIP(), c.GetHeader("User-Agent")) + if err != nil { + apiErr := apierror.Unauthorized("invalid or expired session") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, AuthResponse{Data: data}) +} + +func (h *Handler) SetupTOTP(c *gin.Context) { + userID := GetUserID(c) + u, err := h.userRepo.GetByID(c.Request.Context(), userID) + if err != nil { + apiErr := apierror.Internal("failed to get user") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + data, err := h.service.SetupTOTP(c.Request.Context(), userID, u.Email) + if err != nil { + apiErr := apierror.Internal("failed to setup 2FA") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, TOTPSetupResponse{Data: data}) +} + +func (h *Handler) VerifyTOTP(c *gin.Context) { + var req TOTPVerifyRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + userID := GetUserID(c) + if err := h.service.VerifyTOTPSetup(c.Request.Context(), userID, req.Code); err != nil { + apiErr := apierror.BadRequest("invalid_code", err.Error()) + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "2FA enabled"}}) +} + +func (h *Handler) DisableTOTP(c *gin.Context) { + var req TOTPVerifyRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + userID := GetUserID(c) + if err := h.service.DisableTOTP(c.Request.Context(), userID, req.Code); err != nil { + apiErr := apierror.BadRequest("invalid_code", err.Error()) + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "2FA disabled"}}) +} + +func extractSessionToken(c *gin.Context) string { + header := c.GetHeader("X-Session-Token") + if header != "" { + return header + } + // Also check Authorization with Bearer prefix for refresh + auth := c.GetHeader("Authorization") + if strings.HasPrefix(auth, "Session ") { + return strings.TrimPrefix(auth, "Session ") + } + return "" +} + +func GetUserID(c *gin.Context) uuid.UUID { + v, exists := c.Get("user_id") + if !exists { + return uuid.Nil + } + id, ok := v.(uuid.UUID) + if !ok { + return uuid.Nil + } + return id +} diff --git a/backend/internal/domain/auth/magiclink.go b/backend/internal/domain/auth/magiclink.go new file mode 100644 index 0000000..648a5ab --- /dev/null +++ b/backend/internal/domain/auth/magiclink.go @@ -0,0 +1,140 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "marktvogt.de/backend/internal/config" + "marktvogt.de/backend/internal/domain/user" + "marktvogt.de/backend/internal/pkg/apierror" + "marktvogt.de/backend/internal/pkg/validate" +) + +type MagicLinkHandler struct { + authRepo Repository + userRepo user.Repository + service *Service + cfg config.MagicLinkConfig +} + +func NewMagicLinkHandler(authRepo Repository, userRepo user.Repository, service *Service, cfg config.MagicLinkConfig) *MagicLinkHandler { + return &MagicLinkHandler{ + authRepo: authRepo, + userRepo: userRepo, + service: service, + cfg: cfg, + } +} + +func (h *MagicLinkHandler) RequestMagicLink(c *gin.Context) { + var req MagicLinkRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + ctx := c.Request.Context() + + token := uuid.New().String() + tokenHash := HashToken(token) + + link := MagicLink{ + ID: uuid.New(), + Email: req.Email, + TokenHash: tokenHash, + ExpiresAt: time.Now().Add(h.cfg.TTL), + } + + if err := h.authRepo.CreateMagicLink(ctx, link); err != nil { + apiErr := apierror.Internal("failed to create magic link") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + // TODO: Send email with magic link + // For now, log the link in development + magicURL := fmt.Sprintf("%s?token=%s", h.cfg.BaseURL, token) + slog.Info("magic link created (send via email in production)", "email", req.Email, "url", magicURL) + + c.JSON(http.StatusOK, MessageResponse{Data: MessageData{ + Message: "if an account exists with that email, a magic link has been sent", + }}) +} + +func (h *MagicLinkHandler) VerifyMagicLink(c *gin.Context) { + token := c.Query("token") + if token == "" { + apiErr := apierror.BadRequest("missing_token", "token parameter is required") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + ctx := c.Request.Context() + tokenHash := HashToken(token) + + ml, err := h.authRepo.GetMagicLinkByTokenHash(ctx, tokenHash) + if err != nil { + if errors.Is(err, ErrMagicLinkNotFound) || errors.Is(err, ErrMagicLinkExpired) || errors.Is(err, ErrMagicLinkUsed) { + apiErr := apierror.BadRequest("invalid_token", "magic link is invalid, expired, or already used") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + apiErr := apierror.Internal("failed to verify magic link") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + // Mark as used + if err := h.authRepo.MarkMagicLinkUsed(ctx, ml.ID); err != nil { + apiErr := apierror.Internal("failed to verify magic link") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + // Find or create user + u, err := h.findOrCreateUser(ctx, ml.Email) + if err != nil { + apiErr := apierror.Internal("failed to process magic link") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + // Mark email as verified + if !u.EmailVerified { + _, _ = h.userRepo.Update(ctx, u.ID, map[string]any{"email_verified": true}) + } + + data, err := h.service.createTokenPair(ctx, u, c.ClientIP(), c.GetHeader("User-Agent")) + if err != nil { + apiErr := apierror.Internal("failed to create session") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, AuthResponse{Data: data}) +} + +func (h *MagicLinkHandler) findOrCreateUser(ctx context.Context, email string) (user.User, error) { + u, err := h.userRepo.GetByEmail(ctx, email) + if err == nil { + return u, nil + } + if !errors.Is(err, user.ErrUserNotFound) { + return user.User{}, err + } + + // Create new user without password + return h.userRepo.CreateOAuthUser(ctx, email, "", true) +} + +func RegisterMagicLinkRoutes(rg *gin.RouterGroup, h *MagicLinkHandler) { + rg.POST("/auth/magic-link", h.RequestMagicLink) + rg.GET("/auth/magic-link/verify", h.VerifyMagicLink) +} diff --git a/backend/internal/domain/auth/model.go b/backend/internal/domain/auth/model.go new file mode 100644 index 0000000..3ba474a --- /dev/null +++ b/backend/internal/domain/auth/model.go @@ -0,0 +1,47 @@ +package auth + +import ( + "time" + + "github.com/google/uuid" +) + +type Session struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenHash string `json:"-"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +type MagicLink struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + TokenHash string `json:"-"` + Used bool `json:"used"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +type OAuthAccount struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Provider string `json:"provider"` + ProviderUID string `json:"provider_uid"` + Email string `json:"email"` + AccessToken string `json:"-"` + RefreshToken string `json:"-"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TOTPSecret struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Secret string `json:"-"` + Verified bool `json:"verified"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/domain/auth/oauth.go b/backend/internal/domain/auth/oauth.go new file mode 100644 index 0000000..f8df7a2 --- /dev/null +++ b/backend/internal/domain/auth/oauth.go @@ -0,0 +1,345 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + + "marktvogt.de/backend/internal/config" + "marktvogt.de/backend/internal/domain/user" + "marktvogt.de/backend/internal/pkg/apierror" +) + +var googleEndpoint = oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", +} + +var facebookEndpoint = oauth2.Endpoint{ + AuthURL: "https://www.facebook.com/v18.0/dialog/oauth", + TokenURL: "https://graph.facebook.com/v18.0/oauth/access_token", +} + +type OAuthHandler struct { + providers map[string]*oauth2.Config + service *Service + userRepo user.Repository + authRepo Repository +} + +func NewOAuthHandler(cfg config.OAuthConfig, service *Service, userRepo user.Repository, authRepo Repository) *OAuthHandler { + baseURL := cfg.RedirectBaseURL + providers := make(map[string]*oauth2.Config) + + if cfg.Google.ClientID != "" { + providers["google"] = &oauth2.Config{ + ClientID: cfg.Google.ClientID, + ClientSecret: cfg.Google.ClientSecret, + RedirectURL: baseURL + "/api/v1/auth/oauth/google/callback", + Scopes: []string{"openid", "email", "profile"}, + Endpoint: googleEndpoint, + } + } + + if cfg.GitHub.ClientID != "" { + providers["github"] = &oauth2.Config{ + ClientID: cfg.GitHub.ClientID, + ClientSecret: cfg.GitHub.ClientSecret, + RedirectURL: baseURL + "/api/v1/auth/oauth/github/callback", + Scopes: []string{"user:email"}, + Endpoint: github.Endpoint, + } + } + + if cfg.Facebook.ClientID != "" { + providers["facebook"] = &oauth2.Config{ + ClientID: cfg.Facebook.ClientID, + ClientSecret: cfg.Facebook.ClientSecret, + RedirectURL: baseURL + "/api/v1/auth/oauth/facebook/callback", + Scopes: []string{"email", "public_profile"}, + Endpoint: facebookEndpoint, + } + } + + return &OAuthHandler{ + providers: providers, + service: service, + userRepo: userRepo, + authRepo: authRepo, + } +} + +func (h *OAuthHandler) StartOAuth(c *gin.Context) { + provider := c.Param("provider") + cfg, ok := h.providers[provider] + if !ok { + apiErr := apierror.BadRequest("invalid_provider", "unsupported OAuth provider") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + state := uuid.New().String() + url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline) + + c.JSON(http.StatusOK, gin.H{"data": gin.H{"url": url, "state": state}}) +} + +func (h *OAuthHandler) Callback(c *gin.Context) { + provider := c.Param("provider") + cfg, ok := h.providers[provider] + if !ok { + apiErr := apierror.BadRequest("invalid_provider", "unsupported OAuth provider") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + code := c.Query("code") + if code == "" { + apiErr := apierror.BadRequest("missing_code", "authorization code is required") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + ctx := c.Request.Context() + token, err := cfg.Exchange(ctx, code) + if err != nil { + apiErr := apierror.BadRequest("oauth_error", "failed to exchange authorization code") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + info, err := fetchUserInfo(ctx, provider, token) + if err != nil { + apiErr := apierror.Internal("failed to get user info from provider") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + // Check if OAuth account exists + existing, err := h.authRepo.GetOAuthAccount(ctx, provider, info.ID) + if err == nil { + // Existing OAuth account — update tokens and login + _ = h.authRepo.UpdateOAuthTokens(ctx, existing.ID, token.AccessToken, token.RefreshToken, &token.Expiry) + + u, err := h.userRepo.GetByID(ctx, existing.UserID) + if err != nil { + apiErr := apierror.Internal("user not found for OAuth account") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + data, err := h.service.createTokenPair(ctx, u, c.ClientIP(), c.GetHeader("User-Agent")) + if err != nil { + apiErr := apierror.Internal("failed to create session") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, AuthResponse{Data: data}) + return + } + + // New OAuth account — find or create user + var u user.User + u, err = h.userRepo.GetByEmail(ctx, info.Email) + if errors.Is(err, user.ErrUserNotFound) { + // Create new user + u, err = h.userRepo.CreateOAuthUser(ctx, info.Email, info.Name, info.EmailVerified) + if err != nil { + apiErr := apierror.Internal("failed to create user") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + } else if err != nil { + apiErr := apierror.Internal("failed to look up user") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + // Create OAuth account link + oauthAccount := OAuthAccount{ + ID: uuid.New(), + UserID: u.ID, + Provider: provider, + ProviderUID: info.ID, + Email: info.Email, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + ExpiresAt: &token.Expiry, + } + if err := h.authRepo.CreateOAuthAccount(ctx, oauthAccount); err != nil { + apiErr := apierror.Internal("failed to link OAuth account") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + data, err := h.service.createTokenPair(ctx, u, c.ClientIP(), c.GetHeader("User-Agent")) + if err != nil { + apiErr := apierror.Internal("failed to create session") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusCreated, AuthResponse{Data: data}) +} + +type oauthUserInfo struct { + ID string + Email string + Name string + EmailVerified bool +} + +func fetchUserInfo(ctx context.Context, provider string, token *oauth2.Token) (oauthUserInfo, error) { + switch provider { + case "google": + return fetchGoogleUser(ctx, token) + case "github": + return fetchGitHubUser(ctx, token) + case "facebook": + return fetchFacebookUser(ctx, token) + default: + return oauthUserInfo{}, fmt.Errorf("unsupported provider: %s", provider) + } +} + +func fetchGoogleUser(ctx context.Context, token *oauth2.Token) (oauthUserInfo, error) { + resp, err := oauthHTTPGet(ctx, token, "https://www.googleapis.com/oauth2/v2/userinfo") + if err != nil { + return oauthUserInfo{}, err + } + + var data struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + VerifiedEmail bool `json:"verified_email"` + } + if err := json.Unmarshal(resp, &data); err != nil { + return oauthUserInfo{}, fmt.Errorf("parsing google user info: %w", err) + } + + return oauthUserInfo{ + ID: data.ID, + Email: data.Email, + Name: data.Name, + EmailVerified: data.VerifiedEmail, + }, nil +} + +func fetchGitHubUser(ctx context.Context, token *oauth2.Token) (oauthUserInfo, error) { + resp, err := oauthHTTPGet(ctx, token, "https://api.github.com/user") + if err != nil { + return oauthUserInfo{}, err + } + + var data struct { + ID int `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + } + if err := json.Unmarshal(resp, &data); err != nil { + return oauthUserInfo{}, fmt.Errorf("parsing github user info: %w", err) + } + + name := data.Name + if name == "" { + name = data.Login + } + + // GitHub email may be private — fetch from emails endpoint + email := data.Email + if email == "" { + email, _ = fetchGitHubPrimaryEmail(ctx, token) + } + + return oauthUserInfo{ + ID: fmt.Sprintf("%d", data.ID), + Email: email, + Name: name, + EmailVerified: true, + }, nil +} + +func fetchGitHubPrimaryEmail(ctx context.Context, token *oauth2.Token) (string, error) { + resp, err := oauthHTTPGet(ctx, token, "https://api.github.com/user/emails") + if err != nil { + return "", err + } + + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + if err := json.Unmarshal(resp, &emails); err != nil { + return "", err + } + + for _, e := range emails { + if e.Primary && e.Verified { + return e.Email, nil + } + } + return "", fmt.Errorf("no primary verified email found") +} + +func fetchFacebookUser(ctx context.Context, token *oauth2.Token) (oauthUserInfo, error) { + resp, err := oauthHTTPGet(ctx, token, "https://graph.facebook.com/me?fields=id,name,email") + if err != nil { + return oauthUserInfo{}, err + } + + var data struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + if err := json.Unmarshal(resp, &data); err != nil { + return oauthUserInfo{}, fmt.Errorf("parsing facebook user info: %w", err) + } + + return oauthUserInfo{ + ID: data.ID, + Email: data.Email, + Name: data.Name, + EmailVerified: true, + }, nil +} + +func oauthHTTPGet(ctx context.Context, token *oauth2.Token, url string) ([]byte, error) { + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("http get %s: %w", url, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response from %s: %w", url, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d from %s: %s", resp.StatusCode, url, string(body)) + } + + return body, nil +} + +func RegisterOAuthRoutes(rg *gin.RouterGroup, h *OAuthHandler) { + oauth := rg.Group("/auth/oauth") + { + oauth.GET("/:provider", h.StartOAuth) + oauth.GET("/:provider/callback", h.Callback) + } +} diff --git a/backend/internal/domain/auth/repository.go b/backend/internal/domain/auth/repository.go new file mode 100644 index 0000000..d125c2c --- /dev/null +++ b/backend/internal/domain/auth/repository.go @@ -0,0 +1,266 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/valkey-io/valkey-go" +) + +var ( + ErrSessionNotFound = fmt.Errorf("session not found") + ErrSessionExpired = fmt.Errorf("session expired") + ErrMagicLinkNotFound = fmt.Errorf("magic link not found") + ErrMagicLinkExpired = fmt.Errorf("magic link expired") + ErrMagicLinkUsed = fmt.Errorf("magic link already used") +) + +type Repository interface { + CreateSession(ctx context.Context, session Session) error + GetSessionByTokenHash(ctx context.Context, tokenHash string) (Session, error) + DeleteSession(ctx context.Context, id uuid.UUID) error + DeleteUserSessions(ctx context.Context, userID uuid.UUID) error + + CreateMagicLink(ctx context.Context, link MagicLink) error + GetMagicLinkByTokenHash(ctx context.Context, tokenHash string) (MagicLink, error) + MarkMagicLinkUsed(ctx context.Context, id uuid.UUID) error + + CreateOAuthAccount(ctx context.Context, account OAuthAccount) error + GetOAuthAccount(ctx context.Context, provider, providerUID string) (OAuthAccount, error) + UpdateOAuthTokens(ctx context.Context, id uuid.UUID, accessToken, refreshToken string, expiresAt *time.Time) error + + CreateTOTPSecret(ctx context.Context, secret TOTPSecret) error + GetTOTPSecret(ctx context.Context, userID uuid.UUID) (TOTPSecret, error) + VerifyTOTPSecret(ctx context.Context, userID uuid.UUID) error + DeleteTOTPSecret(ctx context.Context, userID uuid.UUID) error +} + +type pgRepository struct { + db *pgxpool.Pool + vk valkey.Client +} + +func NewRepository(db *pgxpool.Pool, vk valkey.Client) Repository { + return &pgRepository{db: db, vk: vk} +} + +// Session methods — dual storage: Valkey (fast) + PostgreSQL (durable) + +func (r *pgRepository) CreateSession(ctx context.Context, session Session) error { + _, err := r.db.Exec(ctx, ` + INSERT INTO sessions (id, user_id, token_hash, ip_address, user_agent, expires_at) + VALUES ($1, $2, $3, $4::inet, $5, $6) + `, session.ID, session.UserID, session.TokenHash, session.IPAddress, session.UserAgent, session.ExpiresAt) + if err != nil { + return fmt.Errorf("creating session in postgres: %w", err) + } + + // Cache in Valkey + data, _ := json.Marshal(session) + ttl := time.Until(session.ExpiresAt) + key := sessionValkeyKey(session.TokenHash) + err = r.vk.Do(ctx, r.vk.B().Set().Key(key).Value(string(data)).Ex(ttl).Build()).Error() + if err != nil { + // Log but don't fail — Postgres is the source of truth + fmt.Printf("warning: failed to cache session in valkey: %v\n", err) + } + + return nil +} + +func (r *pgRepository) GetSessionByTokenHash(ctx context.Context, tokenHash string) (Session, error) { + // Try Valkey first + key := sessionValkeyKey(tokenHash) + result, err := r.vk.Do(ctx, r.vk.B().Get().Key(key).Build()).ToString() + if err == nil && result != "" { + var session Session + if json.Unmarshal([]byte(result), &session) == nil { + if time.Now().Before(session.ExpiresAt) { + return session, nil + } + return Session{}, ErrSessionExpired + } + } + + // Fall back to Postgres + var s Session + err = r.db.QueryRow(ctx, ` + SELECT id, user_id, token_hash, ip_address::text, user_agent, expires_at, created_at + FROM sessions + WHERE token_hash = $1 + `, tokenHash).Scan(&s.ID, &s.UserID, &s.TokenHash, &s.IPAddress, &s.UserAgent, &s.ExpiresAt, &s.CreatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return Session{}, ErrSessionNotFound + } + return Session{}, fmt.Errorf("getting session: %w", err) + } + + if time.Now().After(s.ExpiresAt) { + return Session{}, ErrSessionExpired + } + + return s, nil +} + +func (r *pgRepository) DeleteSession(ctx context.Context, id uuid.UUID) error { + // Get the session first to know the token hash for Valkey cleanup + var tokenHash string + err := r.db.QueryRow(ctx, "SELECT token_hash FROM sessions WHERE id = $1", id).Scan(&tokenHash) + if err == nil { + key := sessionValkeyKey(tokenHash) + _ = r.vk.Do(ctx, r.vk.B().Del().Key(key).Build()).Error() + } + + _, err = r.db.Exec(ctx, "DELETE FROM sessions WHERE id = $1", id) + return err +} + +func (r *pgRepository) DeleteUserSessions(ctx context.Context, userID uuid.UUID) error { + rows, err := r.db.Query(ctx, "SELECT token_hash FROM sessions WHERE user_id = $1", userID) + if err != nil { + return fmt.Errorf("listing sessions for deletion: %w", err) + } + defer rows.Close() + + var keys []string + for rows.Next() { + var hash string + if err := rows.Scan(&hash); err == nil { + keys = append(keys, sessionValkeyKey(hash)) + } + } + + if len(keys) > 0 { + _ = r.vk.Do(ctx, r.vk.B().Del().Key(keys[0]).Build()).Error() + for _, k := range keys[1:] { + _ = r.vk.Do(ctx, r.vk.B().Del().Key(k).Build()).Error() + } + } + + _, err = r.db.Exec(ctx, "DELETE FROM sessions WHERE user_id = $1", userID) + return err +} + +// Magic link methods + +func (r *pgRepository) CreateMagicLink(ctx context.Context, link MagicLink) error { + _, err := r.db.Exec(ctx, ` + INSERT INTO magic_links (id, email, token_hash, expires_at) + VALUES ($1, $2, $3, $4) + `, link.ID, link.Email, link.TokenHash, link.ExpiresAt) + return err +} + +func (r *pgRepository) GetMagicLinkByTokenHash(ctx context.Context, tokenHash string) (MagicLink, error) { + var ml MagicLink + err := r.db.QueryRow(ctx, ` + SELECT id, email, token_hash, used, expires_at, created_at + FROM magic_links + WHERE token_hash = $1 + `, tokenHash).Scan(&ml.ID, &ml.Email, &ml.TokenHash, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return MagicLink{}, ErrMagicLinkNotFound + } + return MagicLink{}, fmt.Errorf("getting magic link: %w", err) + } + + if ml.Used { + return MagicLink{}, ErrMagicLinkUsed + } + if time.Now().After(ml.ExpiresAt) { + return MagicLink{}, ErrMagicLinkExpired + } + + return ml, nil +} + +func (r *pgRepository) MarkMagicLinkUsed(ctx context.Context, id uuid.UUID) error { + _, err := r.db.Exec(ctx, "UPDATE magic_links SET used = TRUE WHERE id = $1", id) + return err +} + +// OAuth account methods + +func (r *pgRepository) CreateOAuthAccount(ctx context.Context, account OAuthAccount) error { + _, err := r.db.Exec(ctx, ` + INSERT INTO oauth_accounts (id, user_id, provider, provider_uid, email, access_token, refresh_token, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, account.ID, account.UserID, account.Provider, account.ProviderUID, account.Email, + account.AccessToken, account.RefreshToken, account.ExpiresAt) + return err +} + +func (r *pgRepository) GetOAuthAccount(ctx context.Context, provider, providerUID string) (OAuthAccount, error) { + var oa OAuthAccount + err := r.db.QueryRow(ctx, ` + SELECT id, user_id, provider, provider_uid, email, access_token, refresh_token, expires_at, created_at, updated_at + FROM oauth_accounts + WHERE provider = $1 AND provider_uid = $2 + `, provider, providerUID).Scan( + &oa.ID, &oa.UserID, &oa.Provider, &oa.ProviderUID, &oa.Email, + &oa.AccessToken, &oa.RefreshToken, &oa.ExpiresAt, &oa.CreatedAt, &oa.UpdatedAt, + ) + if err != nil { + if err == pgx.ErrNoRows { + return OAuthAccount{}, fmt.Errorf("oauth account not found") + } + return OAuthAccount{}, fmt.Errorf("getting oauth account: %w", err) + } + return oa, nil +} + +func (r *pgRepository) UpdateOAuthTokens(ctx context.Context, id uuid.UUID, accessToken, refreshToken string, expiresAt *time.Time) error { + _, err := r.db.Exec(ctx, ` + UPDATE oauth_accounts + SET access_token = $2, refresh_token = $3, expires_at = $4 + WHERE id = $1 + `, id, accessToken, refreshToken, expiresAt) + return err +} + +// TOTP methods + +func (r *pgRepository) CreateTOTPSecret(ctx context.Context, secret TOTPSecret) error { + _, err := r.db.Exec(ctx, ` + INSERT INTO totp_secrets (id, user_id, secret, verified) + VALUES ($1, $2, $3, $4) + `, secret.ID, secret.UserID, secret.Secret, secret.Verified) + return err +} + +func (r *pgRepository) GetTOTPSecret(ctx context.Context, userID uuid.UUID) (TOTPSecret, error) { + var ts TOTPSecret + err := r.db.QueryRow(ctx, ` + SELECT id, user_id, secret, verified, created_at + FROM totp_secrets + WHERE user_id = $1 + `, userID).Scan(&ts.ID, &ts.UserID, &ts.Secret, &ts.Verified, &ts.CreatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return TOTPSecret{}, fmt.Errorf("totp secret not found") + } + return TOTPSecret{}, fmt.Errorf("getting totp secret: %w", err) + } + return ts, nil +} + +func (r *pgRepository) VerifyTOTPSecret(ctx context.Context, userID uuid.UUID) error { + _, err := r.db.Exec(ctx, "UPDATE totp_secrets SET verified = TRUE WHERE user_id = $1", userID) + return err +} + +func (r *pgRepository) DeleteTOTPSecret(ctx context.Context, userID uuid.UUID) error { + _, err := r.db.Exec(ctx, "DELETE FROM totp_secrets WHERE user_id = $1", userID) + return err +} + +func sessionValkeyKey(tokenHash string) string { + return "session:" + tokenHash +} diff --git a/backend/internal/domain/auth/routes.go b/backend/internal/domain/auth/routes.go new file mode 100644 index 0000000..4a1a946 --- /dev/null +++ b/backend/internal/domain/auth/routes.go @@ -0,0 +1,18 @@ +package auth + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) { + auth := rg.Group("/auth") + { + auth.POST("/register", h.Register) + auth.POST("/login", h.Login) + auth.POST("/logout", requireAuth, h.Logout) + auth.POST("/refresh", h.Refresh) + + // 2FA + auth.POST("/2fa/setup", requireAuth, h.SetupTOTP) + auth.POST("/2fa/verify", requireAuth, h.VerifyTOTP) + auth.DELETE("/2fa", requireAuth, h.DisableTOTP) + } +} diff --git a/backend/internal/domain/auth/service.go b/backend/internal/domain/auth/service.go new file mode 100644 index 0000000..e38bc33 --- /dev/null +++ b/backend/internal/domain/auth/service.go @@ -0,0 +1,160 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "marktvogt.de/backend/internal/domain/user" +) + +type Service struct { + authRepo Repository + userRepo user.Repository + tokenSvc *TokenService + sessionTTL time.Duration +} + +func NewService(authRepo Repository, userRepo user.Repository, tokenSvc *TokenService, sessionTTL time.Duration) *Service { + return &Service{ + authRepo: authRepo, + userRepo: userRepo, + tokenSvc: tokenSvc, + sessionTTL: sessionTTL, + } +} + +func (s *Service) Register(ctx context.Context, req RegisterRequest, ip, ua string) (AuthData, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return AuthData{}, fmt.Errorf("hashing password: %w", err) + } + + u, err := s.userRepo.Create(ctx, req.Email, string(hash), req.DisplayName) + if err != nil { + if errors.Is(err, user.ErrEmailAlreadyTaken) { + return AuthData{}, err + } + return AuthData{}, fmt.Errorf("creating user: %w", err) + } + + return s.createTokenPair(ctx, u, ip, ua) +} + +func (s *Service) Login(ctx context.Context, req LoginRequest, ip, ua string) (AuthData, error) { + u, err := s.userRepo.GetByEmail(ctx, req.Email) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + return AuthData{}, fmt.Errorf("invalid credentials") + } + return AuthData{}, fmt.Errorf("finding user: %w", err) + } + + if u.PasswordHash == nil { + return AuthData{}, fmt.Errorf("invalid credentials") + } + + if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(req.Password)); err != nil { + return AuthData{}, fmt.Errorf("invalid credentials") + } + + // Check 2FA if enabled + if req.TOTPCode != "" { + if err := s.validateTOTP(ctx, u.ID, req.TOTPCode); err != nil { + return AuthData{}, err + } + } else { + // Check if user has 2FA enabled + totp, err := s.authRepo.GetTOTPSecret(ctx, u.ID) + if err == nil && totp.Verified { + return AuthData{}, fmt.Errorf("2fa_required") + } + } + + return s.createTokenPair(ctx, u, ip, ua) +} + +func (s *Service) Logout(ctx context.Context, sessionTokenHash string) error { + session, err := s.authRepo.GetSessionByTokenHash(ctx, sessionTokenHash) + if err != nil { + return err + } + return s.authRepo.DeleteSession(ctx, session.ID) +} + +func (s *Service) RefreshToken(ctx context.Context, sessionToken, ip, ua string) (AuthData, error) { + tokenHash := HashToken(sessionToken) + session, err := s.authRepo.GetSessionByTokenHash(ctx, tokenHash) + if err != nil { + return AuthData{}, err + } + + u, err := s.userRepo.GetByID(ctx, session.UserID) + if err != nil { + return AuthData{}, fmt.Errorf("user not found for session: %w", err) + } + + // Delete old session + _ = s.authRepo.DeleteSession(ctx, session.ID) + + // Create new token pair + return s.createTokenPair(ctx, u, ip, ua) +} + +func (s *Service) ValidateSession(ctx context.Context, sessionToken string) (Session, error) { + tokenHash := HashToken(sessionToken) + return s.authRepo.GetSessionByTokenHash(ctx, tokenHash) +} + +func (s *Service) createTokenPair(ctx context.Context, u user.User, ip, ua string) (AuthData, error) { + accessToken, err := s.tokenSvc.CreateAccessToken(u.ID, u.Email, u.Role) + if err != nil { + return AuthData{}, fmt.Errorf("creating access token: %w", err) + } + + sessionToken := GenerateSessionToken() + sessionHash := HashToken(sessionToken) + + session := Session{ + ID: uuid.New(), + UserID: u.ID, + TokenHash: sessionHash, + IPAddress: ip, + UserAgent: ua, + ExpiresAt: time.Now().Add(s.sessionTTL), + } + + if err := s.authRepo.CreateSession(ctx, session); err != nil { + return AuthData{}, fmt.Errorf("creating session: %w", err) + } + + return AuthData{ + AccessToken: accessToken, + SessionToken: sessionToken, + ExpiresIn: s.tokenSvc.AccessTTLSeconds(), + }, nil +} + +func (s *Service) validateTOTP(ctx context.Context, userID uuid.UUID, code string) error { + totp, err := s.authRepo.GetTOTPSecret(ctx, userID) + if err != nil { + return fmt.Errorf("2fa not configured") + } + if !totp.Verified { + return fmt.Errorf("2fa not verified") + } + + if !ValidateTOTP(totp.Secret, code) { + return fmt.Errorf("invalid 2fa code") + } + + return nil +} + +func (s *Service) TokenService() *TokenService { + return s.tokenSvc +} diff --git a/backend/internal/domain/auth/token.go b/backend/internal/domain/auth/token.go new file mode 100644 index 0000000..0a05378 --- /dev/null +++ b/backend/internal/domain/auth/token.go @@ -0,0 +1,84 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type TokenClaims struct { + jwt.RegisteredClaims + UserID uuid.UUID `json:"uid"` + Email string `json:"email"` + Role string `json:"role"` +} + +type TokenService struct { + secret []byte + accessTTL time.Duration +} + +func NewTokenService(secret string, accessTTL time.Duration) *TokenService { + return &TokenService{ + secret: []byte(secret), + accessTTL: accessTTL, + } +} + +func (ts *TokenService) CreateAccessToken(userID uuid.UUID, email, role string) (string, error) { + now := time.Now() + claims := TokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID.String(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ts.accessTTL)), + Issuer: "marktvogt", + }, + UserID: userID, + Email: email, + Role: role, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(ts.secret) + if err != nil { + return "", fmt.Errorf("signing access token: %w", err) + } + return signed, nil +} + +func (ts *TokenService) ValidateAccessToken(tokenString string) (*TokenClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return ts.secret, nil + }) + if err != nil { + return nil, fmt.Errorf("parsing access token: %w", err) + } + + claims, ok := token.Claims.(*TokenClaims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid access token") + } + + return claims, nil +} + +func (ts *TokenService) AccessTTLSeconds() int { + return int(ts.accessTTL.Seconds()) +} + +func HashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +func GenerateSessionToken() string { + return uuid.New().String() +} diff --git a/backend/internal/domain/auth/totp.go b/backend/internal/domain/auth/totp.go new file mode 100644 index 0000000..d7094b2 --- /dev/null +++ b/backend/internal/domain/auth/totp.go @@ -0,0 +1,72 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/pquerna/otp/totp" +) + +func (s *Service) SetupTOTP(ctx context.Context, userID uuid.UUID, email string) (TOTPSetupData, error) { + // Delete any existing unverified TOTP + _ = s.authRepo.DeleteTOTPSecret(ctx, userID) + + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "Marktvogt", + AccountName: email, + }) + if err != nil { + return TOTPSetupData{}, fmt.Errorf("generating totp key: %w", err) + } + + secret := TOTPSecret{ + ID: uuid.New(), + UserID: userID, + Secret: key.Secret(), + Verified: false, + } + + if err := s.authRepo.CreateTOTPSecret(ctx, secret); err != nil { + return TOTPSetupData{}, fmt.Errorf("storing totp secret: %w", err) + } + + return TOTPSetupData{ + Secret: key.Secret(), + URL: key.URL(), + }, nil +} + +func (s *Service) VerifyTOTPSetup(ctx context.Context, userID uuid.UUID, code string) error { + secret, err := s.authRepo.GetTOTPSecret(ctx, userID) + if err != nil { + return fmt.Errorf("totp not configured: %w", err) + } + + if secret.Verified { + return fmt.Errorf("totp already verified") + } + + if !ValidateTOTP(secret.Secret, code) { + return fmt.Errorf("invalid totp code") + } + + return s.authRepo.VerifyTOTPSecret(ctx, userID) +} + +func (s *Service) DisableTOTP(ctx context.Context, userID uuid.UUID, code string) error { + secret, err := s.authRepo.GetTOTPSecret(ctx, userID) + if err != nil { + return fmt.Errorf("totp not configured: %w", err) + } + + if !ValidateTOTP(secret.Secret, code) { + return fmt.Errorf("invalid totp code") + } + + return s.authRepo.DeleteTOTPSecret(ctx, userID) +} + +func ValidateTOTP(secret, code string) bool { + return totp.Validate(code, secret) +} diff --git a/backend/internal/domain/market/dto.go b/backend/internal/domain/market/dto.go new file mode 100644 index 0000000..e782ee2 --- /dev/null +++ b/backend/internal/domain/market/dto.go @@ -0,0 +1,149 @@ +package market + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type SearchParams struct { + Lat *float64 `form:"lat"` + Lon *float64 `form:"lon"` + Radius float64 `form:"radius"` // km, default 25 + Query string `form:"q"` + From string `form:"from"` // date YYYY-MM-DD + To string `form:"to"` // date YYYY-MM-DD + Sort string `form:"sort"` // distance, date, name + Page int `form:"page"` + PerPage int `form:"per_page"` +} + +func (p *SearchParams) Defaults() { + if p.Radius <= 0 { + p.Radius = 25 + } + if p.Radius > 200 { + p.Radius = 200 + } + if p.Sort == "" { + if p.Lat != nil && p.Lon != nil { + p.Sort = "distance" + } else { + p.Sort = "date" + } + } + if p.Page < 1 { + p.Page = 1 + } + if p.PerPage < 1 { + p.PerPage = 20 + } + if p.PerPage > 100 { + p.PerPage = 100 + } +} + +func (p *SearchParams) RadiusMeters() float64 { + return p.Radius * 1000 +} + +func (p *SearchParams) Offset() int { + return (p.Page - 1) * p.PerPage +} + +type ListResponse struct { + Data []MarketSummary `json:"data"` + Meta MetaResponse `json:"meta"` +} + +type DetailResponse struct { + Data MarketDetail `json:"data"` +} + +type MarketSummary struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + Country string `json:"country"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + ImageURL string `json:"image_url"` + OrganizerName string `json:"organizer_name"` + Distance *float64 `json:"distance,omitempty"` +} + +type MarketDetail struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Street string `json:"street"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + Country string `json:"country"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + OpeningHours json.RawMessage `json:"opening_hours"` + AdmissionInfo json.RawMessage `json:"admission_info"` + Website string `json:"website"` + OrganizerName string `json:"organizer_name"` + ImageURL string `json:"image_url"` +} + +type MetaResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +func ToSummary(m Market) MarketSummary { + return MarketSummary{ + ID: m.ID, + Slug: m.Slug, + Name: m.Name, + City: m.City, + State: m.State, + Zip: m.Zip, + Country: m.Country, + Latitude: m.Latitude, + Longitude: m.Longitude, + StartDate: m.StartDate.Format(time.DateOnly), + EndDate: m.EndDate.Format(time.DateOnly), + ImageURL: m.ImageURL, + OrganizerName: m.OrganizerName, + Distance: m.Distance, + } +} + +func ToDetail(m Market) MarketDetail { + return MarketDetail{ + ID: m.ID, + Slug: m.Slug, + Name: m.Name, + Description: m.Description, + Street: m.Street, + City: m.City, + State: m.State, + Zip: m.Zip, + Country: m.Country, + Latitude: m.Latitude, + Longitude: m.Longitude, + StartDate: m.StartDate.Format(time.DateOnly), + EndDate: m.EndDate.Format(time.DateOnly), + OpeningHours: m.OpeningHours, + AdmissionInfo: m.AdmissionInfo, + Website: m.Website, + OrganizerName: m.OrganizerName, + ImageURL: m.ImageURL, + } +} diff --git a/backend/internal/domain/market/handler.go b/backend/internal/domain/market/handler.go new file mode 100644 index 0000000..9a23884 --- /dev/null +++ b/backend/internal/domain/market/handler.go @@ -0,0 +1,76 @@ +package market + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "marktvogt.de/backend/internal/pkg/apierror" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Search(c *gin.Context) { + var params SearchParams + if err := c.ShouldBindQuery(¶ms); err != nil { + apiErr := apierror.BadRequest("invalid_params", err.Error()) + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + params.Defaults() + + markets, total, err := h.service.Search(c.Request.Context(), params) + if err != nil { + apiErr := apierror.Internal("failed to search markets") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + summaries := make([]MarketSummary, 0, len(markets)) + for _, m := range markets { + summaries = append(summaries, ToSummary(m)) + } + + totalPages := total / params.PerPage + if total%params.PerPage != 0 { + totalPages++ + } + + c.JSON(http.StatusOK, ListResponse{ + Data: summaries, + Meta: MetaResponse{ + Page: params.Page, + PerPage: params.PerPage, + Total: total, + TotalPages: totalPages, + }, + }) +} + +func (h *Handler) GetBySlug(c *gin.Context) { + slug := c.Param("slug") + + m, err := h.service.GetBySlug(c.Request.Context(), slug) + if err != nil { + if errors.Is(err, ErrMarketNotFound) { + apiErr := apierror.NotFound("market") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + apiErr := apierror.Internal("failed to get market") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, DetailResponse{ + Data: ToDetail(m), + }) +} diff --git a/backend/internal/domain/market/model.go b/backend/internal/domain/market/model.go new file mode 100644 index 0000000..b2c6c60 --- /dev/null +++ b/backend/internal/domain/market/model.go @@ -0,0 +1,47 @@ +package market + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type Market struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Street string `json:"street"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + Country string `json:"country"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + OpeningHours json.RawMessage `json:"opening_hours"` + AdmissionInfo json.RawMessage `json:"admission_info"` + Website string `json:"website"` + OrganizerName string `json:"organizer_name"` + ImageURL string `json:"image_url"` + IsPublished bool `json:"is_published"` + Distance *float64 `json:"distance,omitempty"` // meters, only set in geo queries + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OpeningHoursEntry struct { + Day string `json:"day"` + Open string `json:"open"` + Close string `json:"close"` +} + +type AdmissionInfo struct { + AdultCents int `json:"adult_cents"` + ChildCents int `json:"child_cents"` + ReducedCents int `json:"reduced_cents"` + FreeUnderAge int `json:"free_under_age"` + Notes string `json:"notes"` +} diff --git a/backend/internal/domain/market/repository.go b/backend/internal/domain/market/repository.go new file mode 100644 index 0000000..1ca6b4e --- /dev/null +++ b/backend/internal/domain/market/repository.go @@ -0,0 +1,199 @@ +package market + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository interface { + Search(ctx context.Context, params SearchParams) ([]Market, int, error) + GetBySlug(ctx context.Context, slug string) (Market, error) +} + +type pgRepository struct { + db *pgxpool.Pool +} + +func NewRepository(db *pgxpool.Pool) Repository { + return &pgRepository{db: db} +} + +func (r *pgRepository) Search(ctx context.Context, params SearchParams) ([]Market, int, error) { + var ( + conditions []string + args []any + argIdx int + ) + + nextArg := func() string { + argIdx++ + return "$" + strconv.Itoa(argIdx) + } + + conditions = append(conditions, "m.is_published = TRUE") + + hasGeo := params.Lat != nil && params.Lon != nil + + // Geo filter — arg order: lon, lat, radius + var lonArgIdx, latArgIdx int + if hasGeo { + lonArg := nextArg() + latArg := nextArg() + radiusArg := nextArg() + lonArgIdx = argIdx - 2 + latArgIdx = argIdx - 1 + conditions = append(conditions, fmt.Sprintf( + "ST_DWithin(m.location, ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography, %s)", + lonArg, latArg, radiusArg, + )) + args = append(args, *params.Lon, *params.Lat, params.RadiusMeters()) + } + + // Full-text search + ILIKE fallback for German compound words + if params.Query != "" { + ftsArg := nextArg() + likeArg := nextArg() + conditions = append(conditions, fmt.Sprintf( + "(m.search_vector @@ plainto_tsquery('german_market', %s) OR m.name ILIKE %s OR m.description ILIKE %s OR m.city ILIKE %s)", + ftsArg, likeArg, likeArg, likeArg, + )) + args = append(args, params.Query, "%"+params.Query+"%") + } + + // Date range filter + if params.From != "" { + fromArg := nextArg() + conditions = append(conditions, fmt.Sprintf("m.end_date >= %s::date", fromArg)) + args = append(args, params.From) + } + if params.To != "" { + toArg := nextArg() + conditions = append(conditions, fmt.Sprintf("m.start_date <= %s::date", toArg)) + args = append(args, params.To) + } + + where := "WHERE " + strings.Join(conditions, " AND ") + + // Distance select expression — reuse the lon/lat args from the geo filter + var distanceExpr string + if hasGeo { + distanceExpr = fmt.Sprintf( + ", ST_Distance(m.location, ST_SetSRID(ST_MakePoint($%d, $%d), 4326)::geography) AS distance", + lonArgIdx, latArgIdx, + ) + } else { + distanceExpr = ", NULL::double precision AS distance" + } + + // Order by + var orderBy string + switch params.Sort { + case "distance": + if hasGeo { + orderBy = "ORDER BY distance ASC" + } else { + orderBy = "ORDER BY m.start_date ASC" + } + case "name": + orderBy = "ORDER BY m.name ASC" + default: // "date" + orderBy = "ORDER BY m.start_date ASC" + } + + // Count query + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM markets m %s", where) + var total int + if err := r.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("counting markets: %w", err) + } + + // Data query + limitArg := nextArg() + offsetArg := nextArg() + args = append(args, params.PerPage, params.Offset()) + + dataQuery := fmt.Sprintf(` + SELECT + m.id, m.slug, m.name, m.description, + ST_Y(m.location::geometry) AS latitude, + ST_X(m.location::geometry) AS longitude, + m.street, m.city, m.state, m.zip, m.country, + m.start_date, m.end_date, + m.opening_hours, m.admission_info, + m.website, m.organizer_name, m.image_url, + m.is_published, m.created_at, m.updated_at + %s + FROM markets m + %s + %s + LIMIT %s OFFSET %s + `, distanceExpr, where, orderBy, limitArg, offsetArg) + + rows, err := r.db.Query(ctx, dataQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("querying markets: %w", err) + } + defer rows.Close() + + var markets []Market + for rows.Next() { + var m Market + if err := rows.Scan( + &m.ID, &m.Slug, &m.Name, &m.Description, + &m.Latitude, &m.Longitude, + &m.Street, &m.City, &m.State, &m.Zip, &m.Country, + &m.StartDate, &m.EndDate, + &m.OpeningHours, &m.AdmissionInfo, + &m.Website, &m.OrganizerName, &m.ImageURL, + &m.IsPublished, &m.CreatedAt, &m.UpdatedAt, + &m.Distance, + ); err != nil { + return nil, 0, fmt.Errorf("scanning market: %w", err) + } + markets = append(markets, m) + } + + return markets, total, rows.Err() +} + +func (r *pgRepository) GetBySlug(ctx context.Context, slug string) (Market, error) { + query := ` + SELECT + m.id, m.slug, m.name, m.description, + ST_Y(m.location::geometry) AS latitude, + ST_X(m.location::geometry) AS longitude, + m.street, m.city, m.state, m.zip, m.country, + m.start_date, m.end_date, + m.opening_hours, m.admission_info, + m.website, m.organizer_name, m.image_url, + m.is_published, m.created_at, m.updated_at + FROM markets m + WHERE m.slug = $1 AND m.is_published = TRUE + ` + + var m Market + err := r.db.QueryRow(ctx, query, slug).Scan( + &m.ID, &m.Slug, &m.Name, &m.Description, + &m.Latitude, &m.Longitude, + &m.Street, &m.City, &m.State, &m.Zip, &m.Country, + &m.StartDate, &m.EndDate, + &m.OpeningHours, &m.AdmissionInfo, + &m.Website, &m.OrganizerName, &m.ImageURL, + &m.IsPublished, &m.CreatedAt, &m.UpdatedAt, + ) + if err != nil { + if err == pgx.ErrNoRows { + return Market{}, ErrMarketNotFound + } + return Market{}, fmt.Errorf("getting market by slug: %w", err) + } + + return m, nil +} + +var ErrMarketNotFound = fmt.Errorf("market not found") diff --git a/backend/internal/domain/market/routes.go b/backend/internal/domain/market/routes.go new file mode 100644 index 0000000..2c8ecc6 --- /dev/null +++ b/backend/internal/domain/market/routes.go @@ -0,0 +1,11 @@ +package market + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(rg *gin.RouterGroup, h *Handler) { + markets := rg.Group("/markets") + { + markets.GET("", h.Search) + markets.GET("/:slug", h.GetBySlug) + } +} diff --git a/backend/internal/domain/market/service.go b/backend/internal/domain/market/service.go new file mode 100644 index 0000000..0442dd5 --- /dev/null +++ b/backend/internal/domain/market/service.go @@ -0,0 +1,26 @@ +package market + +import ( + "context" + "errors" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Search(ctx context.Context, params SearchParams) ([]Market, int, error) { + params.Defaults() + return s.repo.Search(ctx, params) +} + +func (s *Service) GetBySlug(ctx context.Context, slug string) (Market, error) { + if slug == "" { + return Market{}, errors.New("slug is required") + } + return s.repo.GetBySlug(ctx, slug) +} diff --git a/backend/internal/domain/user/dto.go b/backend/internal/domain/user/dto.go new file mode 100644 index 0000000..c38e667 --- /dev/null +++ b/backend/internal/domain/user/dto.go @@ -0,0 +1,34 @@ +package user + +import "github.com/google/uuid" + +type ProfileResponse struct { + Data ProfileData `json:"data"` +} + +type ProfileData struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + Role string `json:"role"` + CreatedAt string `json:"created_at"` +} + +type UpdateProfileRequest struct { + DisplayName *string `json:"display_name" validate:"omitempty,min=1,max=100"` + AvatarURL *string `json:"avatar_url" validate:"omitempty,url"` +} + +func ToProfileData(u User) ProfileData { + return ProfileData{ + ID: u.ID, + Email: u.Email, + EmailVerified: u.EmailVerified, + DisplayName: u.DisplayName, + AvatarURL: u.AvatarURL, + Role: u.Role, + CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"), + } +} diff --git a/backend/internal/domain/user/handler.go b/backend/internal/domain/user/handler.go new file mode 100644 index 0000000..7856aa8 --- /dev/null +++ b/backend/internal/domain/user/handler.go @@ -0,0 +1,96 @@ +package user + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "marktvogt.de/backend/internal/pkg/apierror" + "marktvogt.de/backend/internal/pkg/validate" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) GetProfile(c *gin.Context) { + userID := getUserID(c) + + u, err := h.service.GetProfile(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + apiErr := apierror.NotFound("user") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + apiErr := apierror.Internal("failed to get profile") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, ProfileResponse{Data: ToProfileData(u)}) +} + +func (h *Handler) UpdateProfile(c *gin.Context) { + var req UpdateProfileRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + userID := getUserID(c) + u, err := h.service.UpdateProfile(c.Request.Context(), userID, req) + if err != nil { + apiErr := apierror.Internal("failed to update profile") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, ProfileResponse{Data: ToProfileData(u)}) +} + +func (h *Handler) DeleteProfile(c *gin.Context) { + userID := getUserID(c) + if err := h.service.SoftDelete(c.Request.Context(), userID); err != nil { + apiErr := apierror.Internal("failed to delete account") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": "account scheduled for deletion in 30 days"}}) +} + +func (h *Handler) RestoreProfile(c *gin.Context) { + userID := getUserID(c) + if err := h.service.Restore(c.Request.Context(), userID); err != nil { + msg := err.Error() + if msg == "account not found or not deleted" || msg == "restoration period expired" { + apiErr := apierror.Gone(msg) + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + apiErr := apierror.Internal("failed to restore account") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": "account restored"}}) +} + +func getUserID(c *gin.Context) uuid.UUID { + v, exists := c.Get("user_id") + if !exists { + return uuid.Nil + } + id, ok := v.(uuid.UUID) + if !ok { + return uuid.Nil + } + return id +} diff --git a/backend/internal/domain/user/model.go b/backend/internal/domain/user/model.go new file mode 100644 index 0000000..c66a7f9 --- /dev/null +++ b/backend/internal/domain/user/model.go @@ -0,0 +1,24 @@ +package user + +import ( + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + PasswordHash *string `json:"-"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + Role string `json:"role"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (u User) IsDeleted() bool { + return u.DeletedAt != nil +} diff --git a/backend/internal/domain/user/repository.go b/backend/internal/domain/user/repository.go new file mode 100644 index 0000000..ac5bde0 --- /dev/null +++ b/backend/internal/domain/user/repository.go @@ -0,0 +1,186 @@ +package user + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrUserNotFound = fmt.Errorf("user not found") + ErrEmailAlreadyTaken = fmt.Errorf("email already taken") +) + +type Repository interface { + Create(ctx context.Context, email, passwordHash, displayName string) (User, error) + CreateOAuthUser(ctx context.Context, email, displayName string, emailVerified bool) (User, error) + GetByID(ctx context.Context, id uuid.UUID) (User, error) + GetByEmail(ctx context.Context, email string) (User, error) + Update(ctx context.Context, id uuid.UUID, fields map[string]any) (User, error) + SoftDelete(ctx context.Context, id uuid.UUID) error + Restore(ctx context.Context, id uuid.UUID) error + GetDeletedByID(ctx context.Context, id uuid.UUID) (User, error) +} + +type pgRepository struct { + db *pgxpool.Pool +} + +func NewRepository(db *pgxpool.Pool) Repository { + return &pgRepository{db: db} +} + +func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayName string) (User, error) { + var u User + err := r.db.QueryRow(ctx, ` + INSERT INTO users (email, password_hash, display_name) + VALUES ($1, $2, $3) + RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at + `, email, passwordHash, displayName).Scan( + &u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName, + &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + if isDuplicateKeyError(err) { + return User{}, ErrEmailAlreadyTaken + } + return User{}, fmt.Errorf("creating user: %w", err) + } + return u, nil +} + +func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName string, emailVerified bool) (User, error) { + var u User + err := r.db.QueryRow(ctx, ` + INSERT INTO users (email, email_verified, display_name) + VALUES ($1, $2, $3) + RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at + `, email, emailVerified, displayName).Scan( + &u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName, + &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + if isDuplicateKeyError(err) { + return User{}, ErrEmailAlreadyTaken + } + return User{}, fmt.Errorf("creating oauth user: %w", err) + } + return u, nil +} + +func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (User, error) { + return r.getUser(ctx, "id = $1 AND deleted_at IS NULL", id) +} + +func (r *pgRepository) GetByEmail(ctx context.Context, email string) (User, error) { + return r.getUser(ctx, "email = $1 AND deleted_at IS NULL", email) +} + +func (r *pgRepository) GetDeletedByID(ctx context.Context, id uuid.UUID) (User, error) { + return r.getUser(ctx, "id = $1 AND deleted_at IS NOT NULL", id) +} + +func (r *pgRepository) getUser(ctx context.Context, where string, arg any) (User, error) { + var u User + err := r.db.QueryRow(ctx, fmt.Sprintf(` + SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at + FROM users + WHERE %s + `, where), arg).Scan( + &u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName, + &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + if err == pgx.ErrNoRows { + return User{}, ErrUserNotFound + } + return User{}, fmt.Errorf("getting user: %w", err) + } + return u, nil +} + +func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, fields map[string]any) (User, error) { + if len(fields) == 0 { + return r.GetByID(ctx, id) + } + + setClauses := "" + args := []any{id} + i := 2 + for k, v := range fields { + if setClauses != "" { + setClauses += ", " + } + setClauses += fmt.Sprintf("%s = $%d", k, i) + args = append(args, v) + i++ + } + + var u User + err := r.db.QueryRow(ctx, fmt.Sprintf(` + UPDATE users SET %s + WHERE id = $1 AND deleted_at IS NULL + RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at + `, setClauses), args...).Scan( + &u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName, + &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + if err == pgx.ErrNoRows { + return User{}, ErrUserNotFound + } + return User{}, fmt.Errorf("updating user: %w", err) + } + return u, nil +} + +func (r *pgRepository) SoftDelete(ctx context.Context, id uuid.UUID) error { + tag, err := r.db.Exec(ctx, ` + UPDATE users SET deleted_at = $2 + WHERE id = $1 AND deleted_at IS NULL + `, id, time.Now()) + if err != nil { + return fmt.Errorf("soft deleting user: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserNotFound + } + return nil +} + +func (r *pgRepository) Restore(ctx context.Context, id uuid.UUID) error { + tag, err := r.db.Exec(ctx, ` + UPDATE users SET deleted_at = NULL + WHERE id = $1 AND deleted_at IS NOT NULL + `, id) + if err != nil { + return fmt.Errorf("restoring user: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserNotFound + } + return nil +} + +func isDuplicateKeyError(err error) bool { + return err != nil && (fmt.Sprintf("%v", err) == "ERROR: duplicate key value violates unique constraint" || + contains(err.Error(), "duplicate key") || + contains(err.Error(), "23505")) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/backend/internal/domain/user/routes.go b/backend/internal/domain/user/routes.go new file mode 100644 index 0000000..70dbc48 --- /dev/null +++ b/backend/internal/domain/user/routes.go @@ -0,0 +1,13 @@ +package user + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) { + users := rg.Group("/users") + { + users.GET("/me", requireAuth, h.GetProfile) + users.PATCH("/me", requireAuth, h.UpdateProfile) + users.DELETE("/me", requireAuth, h.DeleteProfile) + users.POST("/me/restore", requireAuth, h.RestoreProfile) + } +} diff --git a/backend/internal/domain/user/service.go b/backend/internal/domain/user/service.go new file mode 100644 index 0000000..54b8fa0 --- /dev/null +++ b/backend/internal/domain/user/service.go @@ -0,0 +1,60 @@ +package user + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetProfile(ctx context.Context, userID uuid.UUID) (User, error) { + return s.repo.GetByID(ctx, userID) +} + +func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, req UpdateProfileRequest) (User, error) { + fields := make(map[string]any) + if req.DisplayName != nil { + fields["display_name"] = *req.DisplayName + } + if req.AvatarURL != nil { + fields["avatar_url"] = *req.AvatarURL + } + + if len(fields) == 0 { + return s.repo.GetByID(ctx, userID) + } + + return s.repo.Update(ctx, userID, fields) +} + +func (s *Service) SoftDelete(ctx context.Context, userID uuid.UUID) error { + return s.repo.SoftDelete(ctx, userID) +} + +func (s *Service) Restore(ctx context.Context, userID uuid.UUID) error { + u, err := s.repo.GetDeletedByID(ctx, userID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return fmt.Errorf("account not found or not deleted") + } + return err + } + + // Check if still within 30-day grace period + gracePeriod := 30 * 24 * time.Hour + if u.DeletedAt != nil && time.Since(*u.DeletedAt) > gracePeriod { + return fmt.Errorf("restoration period expired") + } + + return s.repo.Restore(ctx, userID) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..a586586 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,61 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "marktvogt.de/backend/internal/domain/auth" + "marktvogt.de/backend/internal/pkg/apierror" +) + +func RequireAuth(tokenSvc *auth.TokenService) gin.HandlerFunc { + return func(c *gin.Context) { + claims, ok := extractAndValidate(c, tokenSvc) + if !ok { + return + } + + c.Set("user_id", claims.UserID) + c.Set("user_email", claims.Email) + c.Set("user_role", claims.Role) + c.Next() + } +} + +func OptionalAuth(tokenSvc *auth.TokenService) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if header == "" || !strings.HasPrefix(header, "Bearer ") { + c.Next() + return + } + + claims, _ := extractAndValidate(c, tokenSvc) + if claims != nil { + c.Set("user_id", claims.UserID) + c.Set("user_email", claims.Email) + c.Set("user_role", claims.Role) + } + c.Next() + } +} + +func extractAndValidate(c *gin.Context, tokenSvc *auth.TokenService) (*auth.TokenClaims, bool) { + header := c.GetHeader("Authorization") + if header == "" || !strings.HasPrefix(header, "Bearer ") { + apiErr := apierror.Unauthorized("missing or invalid authorization header") + c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr)) + return nil, false + } + + tokenString := strings.TrimPrefix(header, "Bearer ") + claims, err := tokenSvc.ValidateAccessToken(tokenString) + if err != nil { + apiErr := apierror.Unauthorized("invalid or expired token") + c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr)) + return nil, false + } + + return claims, true +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..a069159 --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func CORS(allowedOrigins string) gin.HandlerFunc { + origins := make(map[string]bool) + for _, o := range strings.Split(allowedOrigins, ",") { + o = strings.TrimSpace(o) + if o != "" { + origins[o] = true + } + } + + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + + if origins[origin] { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Request-ID") + c.Header("Access-Control-Expose-Headers", "X-Request-ID") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Max-Age", "86400") + } + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/backend/internal/middleware/logging.go b/backend/internal/middleware/logging.go new file mode 100644 index 0000000..41b15ed --- /dev/null +++ b/backend/internal/middleware/logging.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "log/slog" + "time" + + "github.com/gin-gonic/gin" +) + +func Logging() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + + attrs := []slog.Attr{ + slog.String("method", c.Request.Method), + slog.String("path", path), + slog.Int("status", status), + slog.Duration("latency", latency), + slog.String("ip", c.ClientIP()), + } + + if query != "" { + attrs = append(attrs, slog.String("query", query)) + } + + if reqID, exists := c.Get("request_id"); exists { + attrs = append(attrs, slog.String("request_id", reqID.(string))) + } + + if len(c.Errors) > 0 { + attrs = append(attrs, slog.String("errors", c.Errors.String())) + } + + msg := "request" + level := slog.LevelInfo + if status >= 500 { + level = slog.LevelError + } else if status >= 400 { + level = slog.LevelWarn + } + + slog.LogAttrs(c.Request.Context(), level, msg, attrs...) + } +} diff --git a/backend/internal/middleware/ratelimit.go b/backend/internal/middleware/ratelimit.go new file mode 100644 index 0000000..7e2d50b --- /dev/null +++ b/backend/internal/middleware/ratelimit.go @@ -0,0 +1,62 @@ +package middleware + +import ( + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" + + "marktvogt.de/backend/internal/pkg/apierror" +) + +type ipLimiter struct { + mu sync.RWMutex + limiters map[string]*rate.Limiter + rps rate.Limit + burst int +} + +func newIPLimiter(rps float64, burst int) *ipLimiter { + return &ipLimiter{ + limiters: make(map[string]*rate.Limiter), + rps: rate.Limit(rps), + burst: burst, + } +} + +func (l *ipLimiter) get(ip string) *rate.Limiter { + l.mu.RLock() + limiter, exists := l.limiters[ip] + l.mu.RUnlock() + + if exists { + return limiter + } + + l.mu.Lock() + defer l.mu.Unlock() + + // Double-check after acquiring write lock + if limiter, exists = l.limiters[ip]; exists { + return limiter + } + + limiter = rate.NewLimiter(l.rps, l.burst) + l.limiters[ip] = limiter + return limiter +} + +func RateLimit(rps float64, burst int) gin.HandlerFunc { + limiter := newIPLimiter(rps, burst) + + return func(c *gin.Context) { + ip := c.ClientIP() + if !limiter.get(ip).Allow() { + apiErr := apierror.TooManyRequests() + c.AbortWithStatusJSON(http.StatusTooManyRequests, apierror.NewResponse(apiErr)) + return + } + c.Next() + } +} diff --git a/backend/internal/middleware/recovery.go b/backend/internal/middleware/recovery.go new file mode 100644 index 0000000..164b0bf --- /dev/null +++ b/backend/internal/middleware/recovery.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "log/slog" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" + + "marktvogt.de/backend/internal/pkg/apierror" +) + +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + slog.Error("panic recovered", + "error", r, + "stack", stack, + "path", c.Request.URL.Path, + "method", c.Request.Method, + ) + + apiErr := apierror.Internal("an unexpected error occurred") + c.AbortWithStatusJSON(http.StatusInternalServerError, apierror.NewResponse(apiErr)) + } + }() + c.Next() + } +} diff --git a/backend/internal/middleware/requestid.go b/backend/internal/middleware/requestid.go new file mode 100644 index 0000000..58380dd --- /dev/null +++ b/backend/internal/middleware/requestid.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const RequestIDHeader = "X-Request-ID" + +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + id := c.GetHeader(RequestIDHeader) + if id == "" { + id = uuid.New().String() + } + c.Set("request_id", id) + c.Header(RequestIDHeader, id) + c.Next() + } +} diff --git a/backend/internal/pkg/apierror/error.go b/backend/internal/pkg/apierror/error.go new file mode 100644 index 0000000..8ad0260 --- /dev/null +++ b/backend/internal/pkg/apierror/error.go @@ -0,0 +1,82 @@ +package apierror + +import ( + "fmt" + "net/http" +) + +type Error struct { + Status int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("[%d] %s: %s", e.Status, e.Code, e.Message) +} + +type Response struct { + Error *ErrorBody `json:"error"` +} + +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func NewResponse(e *Error) Response { + return Response{ + Error: &ErrorBody{ + Code: e.Code, + Message: e.Message, + }, + } +} + +func BadRequest(code, message string) *Error { + return &Error{Status: http.StatusBadRequest, Code: code, Message: message} +} + +func Unauthorized(message string) *Error { + return &Error{Status: http.StatusUnauthorized, Code: "unauthorized", Message: message} +} + +func Forbidden(message string) *Error { + return &Error{Status: http.StatusForbidden, Code: "forbidden", Message: message} +} + +func NotFound(resource string) *Error { + return &Error{ + Status: http.StatusNotFound, + Code: "not_found", + Message: fmt.Sprintf("%s not found", resource), + } +} + +func Conflict(message string) *Error { + return &Error{Status: http.StatusConflict, Code: "conflict", Message: message} +} + +func TooManyRequests() *Error { + return &Error{ + Status: http.StatusTooManyRequests, + Code: "rate_limited", + Message: "too many requests, please try again later", + } +} + +func Internal(message string) *Error { + return &Error{ + Status: http.StatusInternalServerError, + Code: "internal_error", + Message: message, + } +} + +func Validation(message string) *Error { + return &Error{Status: http.StatusUnprocessableEntity, Code: "validation_error", Message: message} +} + +func Gone(message string) *Error { + return &Error{Status: http.StatusGone, Code: "gone", Message: message} +} diff --git a/backend/internal/pkg/pagination/pagination.go b/backend/internal/pkg/pagination/pagination.go new file mode 100644 index 0000000..9b969ff --- /dev/null +++ b/backend/internal/pkg/pagination/pagination.go @@ -0,0 +1,75 @@ +package pagination + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +const ( + DefaultPage = 1 + DefaultPerPage = 20 + MaxPerPage = 100 +) + +type Params struct { + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +func (p Params) Offset() int { + return (p.Page - 1) * p.PerPage +} + +func (p Params) Limit() int { + return p.PerPage +} + +type Meta struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +func NewMeta(params Params, total int) Meta { + totalPages := total / params.PerPage + if total%params.PerPage != 0 { + totalPages++ + } + return Meta{ + Page: params.Page, + PerPage: params.PerPage, + Total: total, + TotalPages: totalPages, + } +} + +func FromQuery(c *gin.Context) Params { + page := queryInt(c, "page", DefaultPage) + perPage := queryInt(c, "per_page", DefaultPerPage) + + if page < 1 { + page = DefaultPage + } + if perPage < 1 { + perPage = DefaultPerPage + } + if perPage > MaxPerPage { + perPage = MaxPerPage + } + + return Params{Page: page, PerPage: perPage} +} + +func queryInt(c *gin.Context, key string, fallback int) int { + v := c.Query(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} diff --git a/backend/internal/pkg/validate/validate.go b/backend/internal/pkg/validate/validate.go new file mode 100644 index 0000000..ea4162a --- /dev/null +++ b/backend/internal/pkg/validate/validate.go @@ -0,0 +1,51 @@ +package validate + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + + "marktvogt.de/backend/internal/pkg/apierror" +) + +var v = validator.New() + +func Struct(s any) *apierror.Error { + if err := v.Struct(s); err != nil { + var msgs []string + for _, fe := range err.(validator.ValidationErrors) { + msgs = append(msgs, formatFieldError(fe)) + } + return apierror.Validation(strings.Join(msgs, "; ")) + } + return nil +} + +func BindJSON(c *gin.Context, dest any) *apierror.Error { + if err := c.ShouldBindJSON(dest); err != nil { + return apierror.BadRequest("invalid_json", fmt.Sprintf("invalid request body: %s", err.Error())) + } + return Struct(dest) +} + +func formatFieldError(fe validator.FieldError) string { + field := fe.Field() + switch fe.Tag() { + case "required": + return fmt.Sprintf("%s is required", field) + case "email": + return fmt.Sprintf("%s must be a valid email address", field) + case "min": + return fmt.Sprintf("%s must be at least %s characters", field, fe.Param()) + case "max": + return fmt.Sprintf("%s must be at most %s characters", field, fe.Param()) + case "gte": + return fmt.Sprintf("%s must be at least %s", field, fe.Param()) + case "lte": + return fmt.Sprintf("%s must be at most %s", field, fe.Param()) + default: + return fmt.Sprintf("%s failed validation: %s", field, fe.Tag()) + } +} diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go new file mode 100644 index 0000000..5bb9dba --- /dev/null +++ b/backend/internal/server/routes.go @@ -0,0 +1,73 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "marktvogt.de/backend/internal/domain/auth" + "marktvogt.de/backend/internal/domain/market" + "marktvogt.de/backend/internal/domain/user" + "marktvogt.de/backend/internal/middleware" +) + +func (s *Server) registerRoutes() { + s.router.GET("/healthz", s.healthz) + s.router.GET("/readyz", s.readyz) + + v1 := s.router.Group("/api/v1") + + // Market routes (public) + marketRepo := market.NewRepository(s.db) + marketSvc := market.NewService(marketRepo) + marketHandler := market.NewHandler(marketSvc) + market.RegisterRoutes(v1, marketHandler) + + // Auth + userRepo := user.NewRepository(s.db) + tokenSvc := auth.NewTokenService(s.cfg.JWT.Secret, s.cfg.JWT.AccessTTL) + authRepo := auth.NewRepository(s.db, s.valkey) + authSvc := auth.NewService(authRepo, userRepo, tokenSvc, s.cfg.JWT.SessionTTL) + authHandler := auth.NewHandler(authSvc, userRepo) + requireAuth := middleware.RequireAuth(tokenSvc) + auth.RegisterRoutes(v1, authHandler, requireAuth) + + // OAuth routes + oauthHandler := auth.NewOAuthHandler(s.cfg.OAuth, authSvc, userRepo, authRepo) + auth.RegisterOAuthRoutes(v1, oauthHandler) + + // Magic link routes + magicLinkHandler := auth.NewMagicLinkHandler(authRepo, userRepo, authSvc, s.cfg.Magic) + auth.RegisterMagicLinkRoutes(v1, magicLinkHandler) + + // User profile routes + userSvc := user.NewService(userRepo) + userHandler := user.NewHandler(userSvc) + user.RegisterRoutes(v1, userHandler, requireAuth) +} + +func (s *Server) healthz(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (s *Server) readyz(c *gin.Context) { + ctx := c.Request.Context() + + if err := s.db.Ping(ctx); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "unavailable", + "error": "database not reachable", + }) + return + } + + if err := s.valkey.Do(ctx, s.valkey.B().Ping().Build()).Error(); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "unavailable", + "error": "valkey not reachable", + }) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ready"}) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..6b76c92 --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,71 @@ +package server + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/valkey-io/valkey-go" + + "marktvogt.de/backend/internal/config" + "marktvogt.de/backend/internal/middleware" +) + +type Server struct { + cfg *config.Config + router *gin.Engine + http *http.Server + db *pgxpool.Pool + valkey valkey.Client +} + +func New(cfg *config.Config, db *pgxpool.Pool, vk valkey.Client) *Server { + if !cfg.IsDev() { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.New() + + router.Use( + middleware.Recovery(), + middleware.RequestID(), + middleware.Logging(), + middleware.CORS(cfg.CORS.Origins), + middleware.RateLimit(cfg.Rate.RPS, cfg.Rate.Burst), + ) + + s := &Server{ + cfg: cfg, + router: router, + db: db, + valkey: vk, + http: &http.Server{ + Addr: cfg.Addr(), + Handler: router, + }, + } + + s.registerRoutes() + + return s +} + +func (s *Server) Start() error { + slog.Info("starting server", "addr", s.cfg.Addr(), "env", s.cfg.App.Env) + if err := s.http.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server listen: %w", err) + } + return nil +} + +func (s *Server) Shutdown(ctx context.Context) error { + slog.Info("shutting down server") + return s.http.Shutdown(ctx) +} + +func (s *Server) Router() *gin.Engine { + return s.router +} diff --git a/backend/migrations/000001_create_users.down.sql b/backend/migrations/000001_create_users.down.sql new file mode 100644 index 0000000..d951d05 --- /dev/null +++ b/backend/migrations/000001_create_users.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +DROP FUNCTION IF EXISTS update_updated_at(); +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/000001_create_users.up.sql b/backend/migrations/000001_create_users.up.sql new file mode 100644 index 0000000..df8623f --- /dev/null +++ b/backend/migrations/000001_create_users.up.sql @@ -0,0 +1,30 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + password_hash TEXT, + display_name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user', + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_users_email_active ON users (email) WHERE deleted_at IS NULL; + +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); diff --git a/backend/migrations/000002_create_sessions.down.sql b/backend/migrations/000002_create_sessions.down.sql new file mode 100644 index 0000000..63d205d --- /dev/null +++ b/backend/migrations/000002_create_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sessions; diff --git a/backend/migrations/000002_create_sessions.up.sql b/backend/migrations/000002_create_sessions.up.sql new file mode 100644 index 0000000..822f8cd --- /dev/null +++ b/backend/migrations/000002_create_sessions.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + ip_address INET, + user_agent TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_sessions_user_id ON sessions (user_id); +CREATE INDEX idx_sessions_expires_at ON sessions (expires_at); diff --git a/backend/migrations/000003_create_oauth_accounts.down.sql b/backend/migrations/000003_create_oauth_accounts.down.sql new file mode 100644 index 0000000..c16386e --- /dev/null +++ b/backend/migrations/000003_create_oauth_accounts.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trg_oauth_accounts_updated_at ON oauth_accounts; +DROP TABLE IF EXISTS oauth_accounts; diff --git a/backend/migrations/000003_create_oauth_accounts.up.sql b/backend/migrations/000003_create_oauth_accounts.up.sql new file mode 100644 index 0000000..5c9d4ae --- /dev/null +++ b/backend/migrations/000003_create_oauth_accounts.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE oauth_accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_uid TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + refresh_token TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_oauth_provider_uid ON oauth_accounts (provider, provider_uid); +CREATE INDEX idx_oauth_user_id ON oauth_accounts (user_id); + +CREATE TRIGGER trg_oauth_accounts_updated_at + BEFORE UPDATE ON oauth_accounts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); diff --git a/backend/migrations/000004_create_magic_links.down.sql b/backend/migrations/000004_create_magic_links.down.sql new file mode 100644 index 0000000..a2c3b82 --- /dev/null +++ b/backend/migrations/000004_create_magic_links.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS magic_links; diff --git a/backend/migrations/000004_create_magic_links.up.sql b/backend/migrations/000004_create_magic_links.up.sql new file mode 100644 index 0000000..5d37b4c --- /dev/null +++ b/backend/migrations/000004_create_magic_links.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE magic_links ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + used BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_magic_links_email ON magic_links (email); +CREATE INDEX idx_magic_links_expires_at ON magic_links (expires_at); diff --git a/backend/migrations/000005_create_markets.down.sql b/backend/migrations/000005_create_markets.down.sql new file mode 100644 index 0000000..3ef53e5 --- /dev/null +++ b/backend/migrations/000005_create_markets.down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER IF EXISTS trg_markets_updated_at ON markets; +DROP TRIGGER IF EXISTS trg_markets_search_vector ON markets; +DROP FUNCTION IF EXISTS markets_search_vector_update(); +DROP TEXT SEARCH CONFIGURATION IF EXISTS german_market; +DROP TABLE IF EXISTS markets; diff --git a/backend/migrations/000005_create_markets.up.sql b/backend/migrations/000005_create_markets.up.sql new file mode 100644 index 0000000..126cc30 --- /dev/null +++ b/backend/migrations/000005_create_markets.up.sql @@ -0,0 +1,63 @@ +CREATE EXTENSION IF NOT EXISTS "postgis"; + +CREATE TABLE markets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + location GEOGRAPHY(Point, 4326) NOT NULL, + street TEXT NOT NULL DEFAULT '', + city TEXT NOT NULL, + state TEXT NOT NULL DEFAULT '', + zip TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT 'DE', + start_date DATE NOT NULL, + end_date DATE NOT NULL, + opening_hours JSONB NOT NULL DEFAULT '[]', + admission_info JSONB NOT NULL DEFAULT '{}', + website TEXT NOT NULL DEFAULT '', + organizer_name TEXT NOT NULL DEFAULT '', + image_url TEXT NOT NULL DEFAULT '', + is_published BOOLEAN NOT NULL DEFAULT TRUE, + search_vector TSVECTOR, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_markets_location ON markets USING GIST (location); +CREATE INDEX idx_markets_dates ON markets (start_date, end_date); +CREATE INDEX idx_markets_search ON markets USING GIN (search_vector); +CREATE INDEX idx_markets_slug ON markets (slug); +CREATE INDEX idx_markets_published ON markets (is_published) WHERE is_published = TRUE; + +-- German full-text search config (no IF NOT EXISTS for TEXT SEARCH CONFIGURATION) +DO $do$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_ts_config WHERE cfgname = 'german_market') THEN + CREATE TEXT SEARCH CONFIGURATION german_market (COPY = pg_catalog.german); + END IF; +END +$do$; + +-- Weighted FTS trigger: name=A, city=A, description=B, zip=B +CREATE OR REPLACE FUNCTION markets_search_vector_update() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('german_market', COALESCE(NEW.name, '')), 'A') || + setweight(to_tsvector('german_market', COALESCE(NEW.city, '')), 'A') || + setweight(to_tsvector('german_market', COALESCE(NEW.description, '')), 'B') || + setweight(to_tsvector('german_market', COALESCE(NEW.zip, '')), 'B'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_markets_search_vector + BEFORE INSERT OR UPDATE ON markets + FOR EACH ROW + EXECUTE FUNCTION markets_search_vector_update(); + +CREATE TRIGGER trg_markets_updated_at + BEFORE UPDATE ON markets + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); diff --git a/backend/migrations/000006_create_totp_secrets.down.sql b/backend/migrations/000006_create_totp_secrets.down.sql new file mode 100644 index 0000000..d63697b --- /dev/null +++ b/backend/migrations/000006_create_totp_secrets.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS totp_secrets; diff --git a/backend/migrations/000006_create_totp_secrets.up.sql b/backend/migrations/000006_create_totp_secrets.up.sql new file mode 100644 index 0000000..8987fb1 --- /dev/null +++ b/backend/migrations/000006_create_totp_secrets.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE totp_secrets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + secret TEXT NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_totp_secrets_user_id ON totp_secrets (user_id);