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
This commit is contained in:
2026-02-18 05:52:20 +01:00
parent 9784d93a4a
commit a1d93f7a8e
63 changed files with 4237 additions and 0 deletions

49
backend/.env.example Normal file
View File

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

42
backend/.gitignore vendored Normal file
View File

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

60
backend/.golangci.yml Normal file
View File

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

37
backend/.woodpecker.yml Normal file
View File

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

60
backend/Justfile Normal file
View File

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

75
backend/cmd/api/main.go Normal file
View File

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

214
backend/cmd/seed/main.go Normal file
View File

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

29
backend/deploy/Dockerfile Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: marktvogt

View File

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

52
backend/go.mod Normal file
View File

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

115
backend/go.sum Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&params); 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),
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS sessions;

View File

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

View File

@@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS trg_oauth_accounts_updated_at ON oauth_accounts;
DROP TABLE IF EXISTS oauth_accounts;

View File

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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS magic_links;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS totp_secrets;

View File

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