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:
49
backend/.env.example
Normal file
49
backend/.env.example
Normal 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
42
backend/.gitignore
vendored
Normal 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
60
backend/.golangci.yml
Normal 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
37
backend/.woodpecker.yml
Normal 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
60
backend/Justfile
Normal 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
75
backend/cmd/api/main.go
Normal 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
214
backend/cmd/seed/main.go
Normal 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
29
backend/deploy/Dockerfile
Normal 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"]
|
||||
32
backend/deploy/docker-compose.yml
Normal file
32
backend/deploy/docker-compose.yml
Normal 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:
|
||||
44
backend/deploy/k8s/deployment.yml
Normal file
44
backend/deploy/k8s/deployment.yml
Normal 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
|
||||
23
backend/deploy/k8s/ingress.yml
Normal file
23
backend/deploy/k8s/ingress.yml
Normal 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
|
||||
4
backend/deploy/k8s/namespace.yml
Normal file
4
backend/deploy/k8s/namespace.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: marktvogt
|
||||
13
backend/deploy/k8s/service.yml
Normal file
13
backend/deploy/k8s/service.yml
Normal 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
52
backend/go.mod
Normal 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
115
backend/go.sum
Normal 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=
|
||||
256
backend/internal/config/config.go
Normal file
256
backend/internal/config/config.go
Normal 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
|
||||
}
|
||||
34
backend/internal/database/postgres.go
Normal file
34
backend/internal/database/postgres.go
Normal 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
|
||||
}
|
||||
37
backend/internal/database/valkey.go
Normal file
37
backend/internal/database/valkey.go
Normal 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
|
||||
}
|
||||
48
backend/internal/domain/auth/dto.go
Normal file
48
backend/internal/domain/auth/dto.go
Normal 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"`
|
||||
}
|
||||
187
backend/internal/domain/auth/handler.go
Normal file
187
backend/internal/domain/auth/handler.go
Normal 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
|
||||
}
|
||||
140
backend/internal/domain/auth/magiclink.go
Normal file
140
backend/internal/domain/auth/magiclink.go
Normal 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)
|
||||
}
|
||||
47
backend/internal/domain/auth/model.go
Normal file
47
backend/internal/domain/auth/model.go
Normal 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"`
|
||||
}
|
||||
345
backend/internal/domain/auth/oauth.go
Normal file
345
backend/internal/domain/auth/oauth.go
Normal 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)
|
||||
}
|
||||
}
|
||||
266
backend/internal/domain/auth/repository.go
Normal file
266
backend/internal/domain/auth/repository.go
Normal 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
|
||||
}
|
||||
18
backend/internal/domain/auth/routes.go
Normal file
18
backend/internal/domain/auth/routes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
160
backend/internal/domain/auth/service.go
Normal file
160
backend/internal/domain/auth/service.go
Normal 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
|
||||
}
|
||||
84
backend/internal/domain/auth/token.go
Normal file
84
backend/internal/domain/auth/token.go
Normal 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()
|
||||
}
|
||||
72
backend/internal/domain/auth/totp.go
Normal file
72
backend/internal/domain/auth/totp.go
Normal 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)
|
||||
}
|
||||
149
backend/internal/domain/market/dto.go
Normal file
149
backend/internal/domain/market/dto.go
Normal 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,
|
||||
}
|
||||
}
|
||||
76
backend/internal/domain/market/handler.go
Normal file
76
backend/internal/domain/market/handler.go
Normal 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(¶ms); err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_params", err.Error())
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
params.Defaults()
|
||||
|
||||
markets, total, err := h.service.Search(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to search markets")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
summaries := make([]MarketSummary, 0, len(markets))
|
||||
for _, m := range markets {
|
||||
summaries = append(summaries, ToSummary(m))
|
||||
}
|
||||
|
||||
totalPages := total / params.PerPage
|
||||
if total%params.PerPage != 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ListResponse{
|
||||
Data: summaries,
|
||||
Meta: MetaResponse{
|
||||
Page: params.Page,
|
||||
PerPage: params.PerPage,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetBySlug(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
m, err := h.service.GetBySlug(c.Request.Context(), slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMarketNotFound) {
|
||||
apiErr := apierror.NotFound("market")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to get market")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, DetailResponse{
|
||||
Data: ToDetail(m),
|
||||
})
|
||||
}
|
||||
47
backend/internal/domain/market/model.go
Normal file
47
backend/internal/domain/market/model.go
Normal 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"`
|
||||
}
|
||||
199
backend/internal/domain/market/repository.go
Normal file
199
backend/internal/domain/market/repository.go
Normal 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")
|
||||
11
backend/internal/domain/market/routes.go
Normal file
11
backend/internal/domain/market/routes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
26
backend/internal/domain/market/service.go
Normal file
26
backend/internal/domain/market/service.go
Normal 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)
|
||||
}
|
||||
34
backend/internal/domain/user/dto.go
Normal file
34
backend/internal/domain/user/dto.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
96
backend/internal/domain/user/handler.go
Normal file
96
backend/internal/domain/user/handler.go
Normal 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
|
||||
}
|
||||
24
backend/internal/domain/user/model.go
Normal file
24
backend/internal/domain/user/model.go
Normal 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
|
||||
}
|
||||
186
backend/internal/domain/user/repository.go
Normal file
186
backend/internal/domain/user/repository.go
Normal 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
|
||||
}
|
||||
13
backend/internal/domain/user/routes.go
Normal file
13
backend/internal/domain/user/routes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
60
backend/internal/domain/user/service.go
Normal file
60
backend/internal/domain/user/service.go
Normal 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)
|
||||
}
|
||||
61
backend/internal/middleware/auth.go
Normal file
61
backend/internal/middleware/auth.go
Normal 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
|
||||
}
|
||||
38
backend/internal/middleware/cors.go
Normal file
38
backend/internal/middleware/cors.go
Normal 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()
|
||||
}
|
||||
}
|
||||
51
backend/internal/middleware/logging.go
Normal file
51
backend/internal/middleware/logging.go
Normal 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...)
|
||||
}
|
||||
}
|
||||
62
backend/internal/middleware/ratelimit.go
Normal file
62
backend/internal/middleware/ratelimit.go
Normal 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()
|
||||
}
|
||||
}
|
||||
31
backend/internal/middleware/recovery.go
Normal file
31
backend/internal/middleware/recovery.go
Normal 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()
|
||||
}
|
||||
}
|
||||
20
backend/internal/middleware/requestid.go
Normal file
20
backend/internal/middleware/requestid.go
Normal 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()
|
||||
}
|
||||
}
|
||||
82
backend/internal/pkg/apierror/error.go
Normal file
82
backend/internal/pkg/apierror/error.go
Normal 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}
|
||||
}
|
||||
75
backend/internal/pkg/pagination/pagination.go
Normal file
75
backend/internal/pkg/pagination/pagination.go
Normal 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
|
||||
}
|
||||
51
backend/internal/pkg/validate/validate.go
Normal file
51
backend/internal/pkg/validate/validate.go
Normal 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())
|
||||
}
|
||||
}
|
||||
73
backend/internal/server/routes.go
Normal file
73
backend/internal/server/routes.go
Normal 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"})
|
||||
}
|
||||
71
backend/internal/server/server.go
Normal file
71
backend/internal/server/server.go
Normal 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
|
||||
}
|
||||
3
backend/migrations/000001_create_users.down.sql
Normal file
3
backend/migrations/000001_create_users.down.sql
Normal 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;
|
||||
30
backend/migrations/000001_create_users.up.sql
Normal file
30
backend/migrations/000001_create_users.up.sql
Normal 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();
|
||||
1
backend/migrations/000002_create_sessions.down.sql
Normal file
1
backend/migrations/000002_create_sessions.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
12
backend/migrations/000002_create_sessions.up.sql
Normal file
12
backend/migrations/000002_create_sessions.up.sql
Normal 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);
|
||||
2
backend/migrations/000003_create_oauth_accounts.down.sql
Normal file
2
backend/migrations/000003_create_oauth_accounts.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TRIGGER IF EXISTS trg_oauth_accounts_updated_at ON oauth_accounts;
|
||||
DROP TABLE IF EXISTS oauth_accounts;
|
||||
20
backend/migrations/000003_create_oauth_accounts.up.sql
Normal file
20
backend/migrations/000003_create_oauth_accounts.up.sql
Normal 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();
|
||||
1
backend/migrations/000004_create_magic_links.down.sql
Normal file
1
backend/migrations/000004_create_magic_links.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS magic_links;
|
||||
11
backend/migrations/000004_create_magic_links.up.sql
Normal file
11
backend/migrations/000004_create_magic_links.up.sql
Normal 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);
|
||||
5
backend/migrations/000005_create_markets.down.sql
Normal file
5
backend/migrations/000005_create_markets.down.sql
Normal 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;
|
||||
63
backend/migrations/000005_create_markets.up.sql
Normal file
63
backend/migrations/000005_create_markets.up.sql
Normal 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();
|
||||
1
backend/migrations/000006_create_totp_secrets.down.sql
Normal file
1
backend/migrations/000006_create_totp_secrets.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS totp_secrets;
|
||||
9
backend/migrations/000006_create_totp_secrets.up.sql
Normal file
9
backend/migrations/000006_create_totp_secrets.up.sql
Normal 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);
|
||||
Reference in New Issue
Block a user