From bf95949cc5748185afe49fdb875a730406b4df64 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 11 Jan 2026 08:14:48 -0500 Subject: [PATCH] go backend to react-go stack --- stacks/go-react/go-backend/.air.toml | 29 ++ stacks/go-react/go-backend/.env.example | 33 ++ stacks/go-react/go-backend/.gitignore | 44 ++ stacks/go-react/go-backend/.golangci.yml | 113 +++++ stacks/go-react/go-backend/Dockerfile | 36 ++ stacks/go-react/go-backend/Justfile | 110 +++++ stacks/go-react/go-backend/cmd/api/main.go | 221 ++++++++++ stacks/go-react/go-backend/compose.yml | 59 +++ stacks/go-react/go-backend/config.yaml | 53 +++ stacks/go-react/go-backend/dev.compose.yml | 45 ++ stacks/go-react/go-backend/go.mod | 69 +++ stacks/go-react/go-backend/go.sum | 165 +++++++ .../go-backend/internal/admin/handler.go | 208 +++++++++ .../go-react/go-backend/internal/auth/dto.go | 62 +++ .../go-backend/internal/auth/entity.go | 47 ++ .../go-backend/internal/auth/handler.go | 316 ++++++++++++++ .../go-react/go-backend/internal/auth/jwt.go | 295 +++++++++++++ .../go-backend/internal/auth/repository.go | 236 ++++++++++ .../go-backend/internal/auth/service.go | 411 ++++++++++++++++++ .../go-backend/internal/config/config.go | 302 +++++++++++++ .../go-backend/internal/core/database.go | 145 ++++++ .../go-backend/internal/core/errors.go | 169 +++++++ .../go-backend/internal/core/redis.go | 63 +++ .../go-backend/internal/core/response.go | 120 +++++ .../go-backend/internal/core/security.go | 218 ++++++++++ .../go-backend/internal/core/telemetry.go | 142 ++++++ .../go-backend/internal/core/validation.go | 42 ++ .../go-backend/internal/health/handler.go | 195 +++++++++ .../go-backend/internal/middleware/auth.go | 189 ++++++++ .../go-backend/internal/middleware/headers.go | 103 +++++ .../go-backend/internal/middleware/logging.go | 99 +++++ .../internal/middleware/ratelimit.go | 375 ++++++++++++++++ .../internal/middleware/request_id.go | 40 ++ .../go-backend/internal/server/server.go | 108 +++++ .../go-react/go-backend/internal/user/dto.go | 84 ++++ .../go-backend/internal/user/entity.go | 40 ++ .../go-backend/internal/user/handler.go | 288 ++++++++++++ .../go-backend/internal/user/repository.go | 289 ++++++++++++ .../go-backend/internal/user/service.go | 256 +++++++++++ 39 files changed, 5819 insertions(+) create mode 100644 stacks/go-react/go-backend/.air.toml create mode 100644 stacks/go-react/go-backend/.env.example create mode 100644 stacks/go-react/go-backend/.gitignore create mode 100644 stacks/go-react/go-backend/.golangci.yml create mode 100644 stacks/go-react/go-backend/Dockerfile create mode 100644 stacks/go-react/go-backend/Justfile create mode 100644 stacks/go-react/go-backend/cmd/api/main.go create mode 100644 stacks/go-react/go-backend/compose.yml create mode 100644 stacks/go-react/go-backend/config.yaml create mode 100644 stacks/go-react/go-backend/dev.compose.yml create mode 100644 stacks/go-react/go-backend/go.mod create mode 100644 stacks/go-react/go-backend/go.sum create mode 100644 stacks/go-react/go-backend/internal/admin/handler.go create mode 100644 stacks/go-react/go-backend/internal/auth/dto.go create mode 100644 stacks/go-react/go-backend/internal/auth/entity.go create mode 100644 stacks/go-react/go-backend/internal/auth/handler.go create mode 100644 stacks/go-react/go-backend/internal/auth/jwt.go create mode 100644 stacks/go-react/go-backend/internal/auth/repository.go create mode 100644 stacks/go-react/go-backend/internal/auth/service.go create mode 100644 stacks/go-react/go-backend/internal/config/config.go create mode 100644 stacks/go-react/go-backend/internal/core/database.go create mode 100644 stacks/go-react/go-backend/internal/core/errors.go create mode 100644 stacks/go-react/go-backend/internal/core/redis.go create mode 100644 stacks/go-react/go-backend/internal/core/response.go create mode 100644 stacks/go-react/go-backend/internal/core/security.go create mode 100644 stacks/go-react/go-backend/internal/core/telemetry.go create mode 100644 stacks/go-react/go-backend/internal/core/validation.go create mode 100644 stacks/go-react/go-backend/internal/health/handler.go create mode 100644 stacks/go-react/go-backend/internal/middleware/auth.go create mode 100644 stacks/go-react/go-backend/internal/middleware/headers.go create mode 100644 stacks/go-react/go-backend/internal/middleware/logging.go create mode 100644 stacks/go-react/go-backend/internal/middleware/ratelimit.go create mode 100644 stacks/go-react/go-backend/internal/middleware/request_id.go create mode 100644 stacks/go-react/go-backend/internal/server/server.go create mode 100644 stacks/go-react/go-backend/internal/user/dto.go create mode 100644 stacks/go-react/go-backend/internal/user/entity.go create mode 100644 stacks/go-react/go-backend/internal/user/handler.go create mode 100644 stacks/go-react/go-backend/internal/user/repository.go create mode 100644 stacks/go-react/go-backend/internal/user/service.go diff --git a/stacks/go-react/go-backend/.air.toml b/stacks/go-react/go-backend/.air.toml new file mode 100644 index 0000000..5f03fe9 --- /dev/null +++ b/stacks/go-react/go-backend/.air.toml @@ -0,0 +1,29 @@ +# AngelaMos | 2026 +# .air.toml - Hot reload configuration + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "./tmp/main" +include_ext = ["go", "yaml", "yml"] +exclude_dir = ["tmp", "vendor", "bin", "keys", "migrations"] +exclude_regex = ["_test\\.go"] +delay = 1000 +stop_on_error = true +send_interrupt = true +kill_delay = 500 + +[log] +time = false + +[color] +main = "cyan" +watcher = "magenta" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/stacks/go-react/go-backend/.env.example b/stacks/go-react/go-backend/.env.example new file mode 100644 index 0000000..f542823 --- /dev/null +++ b/stacks/go-react/go-backend/.env.example @@ -0,0 +1,33 @@ +# AngelaMos | 2026 +# .env.example + +# Environment: development, staging, production +ENVIRONMENT=development + +# Server +HOST=0.0.0.0 +PORT=8080 + +# Database +DATABASE_URL=postgres://postgres:postgres@localhost:5432/app?sslmode=disable + +# Redis +REDIS_URL=redis://localhost:6379/0 + +# JWT +JWT_PRIVATE_KEY_PATH=keys/private.pem +JWT_PUBLIC_KEY_PATH=keys/public.pem +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=1m + +# OpenTelemetry +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=go-backend + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text diff --git a/stacks/go-react/go-backend/.gitignore b/stacks/go-react/go-backend/.gitignore new file mode 100644 index 0000000..3688c87 --- /dev/null +++ b/stacks/go-react/go-backend/.gitignore @@ -0,0 +1,44 @@ +# AngelaMos | 2026 +# .gitignore + +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test +*.test +coverage.out +coverage.html + +# Build +tmp/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Keys (sensitive) +keys/*.pem +keys/*.key + +# Vendor (if using) +vendor/ + +# Debug +__debug_bin* diff --git a/stacks/go-react/go-backend/.golangci.yml b/stacks/go-react/go-backend/.golangci.yml new file mode 100644 index 0000000..679c3e2 --- /dev/null +++ b/stacks/go-react/go-backend/.golangci.yml @@ -0,0 +1,113 @@ +# AngelaMos | 2026 +# .golangci.yml + +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - gosec + - bodyclose + - nilerr + - errorlint + - exhaustive + - gocritic + - funlen + - gocognit + - dupl + - goconst + - ineffassign + - unused + - unconvert + - unparam + - testifylint + - fatcontext + + settings: + errcheck: + check-type-assertions: true + check-blank: true + + funlen: + lines: 100 + statements: 50 + + gocognit: + min-complexity: 20 + + govet: + enable-all: true + disable: + - fieldalignment + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-declaration + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + + staticcheck: + checks: + - all + + gosec: + excludes: + - G104 + + sloglint: + no-mixed-args: true + kv-only: true + context: all + +issues: + max-same-issues: 50 + exclude-dirs: + - vendor + - testdata + exclude-rules: + - path: _test\.go + linters: + - funlen + - dupl + - goconst + + +formatters: + enable: + - gci # Groups imports + - gofumpt # Whitespace + - golines # Vertical wrap + settings: + golines: + max-len: 80 + reformat-tags: true + goimports: + local-prefixes: + - github.com/carterperez-dev/templates/go-backend + gci: + sections: + - standard + - default + - prefix(github.com/carterperez-dev) + custom-order: true + gofumpt: + extra-rules: true diff --git a/stacks/go-react/go-backend/Dockerfile b/stacks/go-react/go-backend/Dockerfile new file mode 100644 index 0000000..d6f0205 --- /dev/null +++ b/stacks/go-react/go-backend/Dockerfile @@ -0,0 +1,36 @@ +# AngelaMos | 2026 +# Dockerfile - Multi-stage distroless build + +# Build stage +FROM golang:1.25-bookworm AS builder + +WORKDIR /build + +# Copy dependency files first for layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build binary +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/api + +# Production stage - Distroless +FROM gcr.io/distroless/static-debian12 + +# Copy binary from builder +COPY --from=builder /app /app + +# Copy migrations (embedded, but useful for debugging) +COPY --from=builder /build/migrations /migrations + +# Copy keys if they exist (for JWT) +COPY --from=builder /build/keys /keys + +# Run as non-root user +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/app"] diff --git a/stacks/go-react/go-backend/Justfile b/stacks/go-react/go-backend/Justfile new file mode 100644 index 0000000..a3cdc63 --- /dev/null +++ b/stacks/go-react/go-backend/Justfile @@ -0,0 +1,110 @@ +# AngelaMos | 2026 +# Justfile + +set dotenv-load := true + +# Default recipe +default: + @just --list + +# Development +# ----------- + +# Run the API server with hot reload (requires air) +dev: + air -c .air.toml + +# Run the API server without hot reload +run: + go run ./cmd/api + +# Build the binary +build: + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o bin/api ./cmd/api + +# Run tests +test: + go test -v -race -coverprofile=coverage.out ./... + +# Run tests with coverage report +test-coverage: test + go tool cover -html=coverage.out -o coverage.html + +# Run linter +lint: + golangci-lint run --timeout=5m + +# Format code +fmt: + gofumpt -w . + goimports -w . + +# Tidy dependencies +tidy: + go mod tidy + +# Database +# -------- + +# Run database migrations +migrate-up: + goose -dir migrations postgres "${DATABASE_URL}" up + +# Rollback last migration +migrate-down: + goose -dir migrations postgres "${DATABASE_URL}" down + +# Check migration status +migrate-status: + goose -dir migrations postgres "${DATABASE_URL}" status + +# Create new migration +migrate-create name: + goose -dir migrations create {{name}} sql + +# Docker +# ------ + +# Start development containers +up: + docker compose -f dev.compose.yml up -d + +# Stop development containers +down: + docker compose -f dev.compose.yml down + +# View container logs +logs: + docker compose -f dev.compose.yml logs -f + +# Rebuild and restart containers +rebuild: + docker compose -f dev.compose.yml up -d --build + +# Build production image +docker-build: + docker build -t go-backend:latest . + +# Keys +# ---- + +# Generate ES256 keypair for JWT signing +generate-keys: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p keys + openssl ecparam -genkey -name prime256v1 -noout -out keys/private.pem + openssl ec -in keys/private.pem -pubout -out keys/public.pem + chmod 600 keys/private.pem + echo "ES256 keypair generated in keys/" + +# Clean +# ----- + +# Remove build artifacts +clean: + rm -rf bin/ coverage.out coverage.html tmp/ + +# Full clean including containers +clean-all: clean + docker compose -f dev.compose.yml down -v diff --git a/stacks/go-react/go-backend/cmd/api/main.go b/stacks/go-react/go-backend/cmd/api/main.go new file mode 100644 index 0000000..150d2b5 --- /dev/null +++ b/stacks/go-react/go-backend/cmd/api/main.go @@ -0,0 +1,221 @@ +// AngelaMos | 2026 +// main.go + +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/carterperez-dev/templates/go-backend/internal/admin" + "github.com/carterperez-dev/templates/go-backend/internal/auth" + "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/health" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" + "github.com/carterperez-dev/templates/go-backend/internal/server" + "github.com/carterperez-dev/templates/go-backend/internal/user" +) + +const ( + drainDelay = 5 * time.Second +) + +func main() { + configPath := flag.String("config", "config.yaml", "path to config file") + flag.Parse() + + if err := run(*configPath); err != nil { + slog.Error("application error", "error", err) + os.Exit(1) + } +} + +//nolint:funlen // bootstrap code is inherently verbose +func run(configPath string) error { + ctx, stop := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGTERM, + ) + defer stop() + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + logger := setupLogger(cfg.Log) + slog.SetDefault(logger) + + logger.Info("starting application", + "name", cfg.App.Name, + "version", cfg.App.Version, + "environment", cfg.App.Environment, + ) + + var telemetry *core.Telemetry + if cfg.Otel.Enabled { + tel, telErr := core.NewTelemetry(ctx, cfg.Otel, cfg.App) + if telErr != nil { + logger.Warn("failed to initialize telemetry", "error", telErr) + } else { + telemetry = tel + logger.Info("OpenTelemetry tracer initialized", + "endpoint", cfg.Otel.Endpoint, + ) + } + } + + db, err := core.NewDatabase(ctx, cfg.Database) + if err != nil { + return err + } + logger.Info("database connected", + "max_open_conns", cfg.Database.MaxOpenConns, + "max_idle_conns", cfg.Database.MaxIdleConns, + ) + + redis, err := core.NewRedis(ctx, cfg.Redis) + if err != nil { + return err + } + logger.Info("redis connected", + "pool_size", cfg.Redis.PoolSize, + ) + + jwtManager, err := auth.NewJWTManager(cfg.JWT) + if err != nil { + return err + } + logger.Info("JWT manager initialized", + "algorithm", "ES256", + "key_id", jwtManager.GetKeyID(), + ) + + userRepo := user.NewRepository(db.DB) + userSvc := user.NewService(userRepo) + userHandler := user.NewHandler(userSvc) + + authRepo := auth.NewRepository(db.DB) + authSvc := auth.NewService(authRepo, jwtManager, userSvc, redis.Client) + authHandler := auth.NewHandler(authSvc) + + healthHandler := health.NewHandler(db, redis) + + adminHandler := admin.NewHandler(admin.HandlerConfig{ + DBStats: db.Stats, + RedisStats: redis.PoolStats, + DBPing: db.Ping, + RedisPing: redis.Ping, + }) + + srv := server.New(server.Config{ + ServerConfig: cfg.Server, + HealthHandler: healthHandler, + Logger: logger, + }) + + router := srv.Router() + + router.Use(middleware.RequestID) + router.Use(middleware.Logger(logger)) + router.Use( + middleware.NewRateLimiter(redis.Client, middleware.RateLimitConfig{ + Limit: middleware.PerMinute( + cfg.RateLimit.Requests, + cfg.RateLimit.Burst, + ), + FailOpen: true, + }).Handler, + ) + router.Use(middleware.SecurityHeaders(cfg.App.Environment == "production")) + router.Use(middleware.CORS(cfg.CORS)) + + healthHandler.RegisterRoutes(router) + + router.Get("/.well-known/jwks.json", jwtManager.GetJWKSHandler()) + + authenticator := middleware.Authenticator(jwtManager) + adminOnly := middleware.RequireAdmin + + router.Route("/v1", func(r chi.Router) { + authHandler.RegisterRoutes(r, authenticator) + + r.Post("/users", authHandler.Register) + + userHandler.RegisterRoutes(r, authenticator) + userHandler.RegisterAdminRoutes(r, authenticator, adminOnly) + adminHandler.RegisterRoutes(r, authenticator, adminOnly) + }) + + errChan := make(chan error, 1) + go func() { + errChan <- srv.Start() + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + logger.Info("shutdown signal received") + } + + shutdownCtx, cancel := context.WithTimeout( + context.Background(), + cfg.Server.ShutdownTimeout+drainDelay+5*time.Second, + ) + defer cancel() + + if err := srv.Shutdown(shutdownCtx, drainDelay); err != nil { + logger.Error("server shutdown error", "error", err) + } + + if telemetry != nil { + if err := telemetry.Shutdown(shutdownCtx); err != nil { + logger.Error("telemetry shutdown error", "error", err) + } + } + + if err := redis.Close(); err != nil { + logger.Error("redis close error", "error", err) + } + + if err := db.Close(); err != nil { + logger.Error("database close error", "error", err) + } + + logger.Info("application stopped") + return nil +} + +func setupLogger(cfg config.LogConfig) *slog.Logger { + var handler slog.Handler + + level := slog.LevelInfo + switch cfg.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + opts := &slog.HandlerOptions{Level: level} + + if cfg.Format == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + + return slog.New(handler) +} diff --git a/stacks/go-react/go-backend/compose.yml b/stacks/go-react/go-backend/compose.yml new file mode 100644 index 0000000..66d66a2 --- /dev/null +++ b/stacks/go-react/go-backend/compose.yml @@ -0,0 +1,59 @@ +# AngelaMos | 2026 +# compose.yml - Production compose + +services: + api: + image: go-backend:latest + ports: + - "8085:8080" + environment: + - ENVIRONMENT=production + - DATABASE_URL=postgres://postgres:postgres@postgres:5432/app?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M + + postgres: + image: postgres:18-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: + redisdata: diff --git a/stacks/go-react/go-backend/config.yaml b/stacks/go-react/go-backend/config.yaml new file mode 100644 index 0000000..01aa28f --- /dev/null +++ b/stacks/go-react/go-backend/config.yaml @@ -0,0 +1,53 @@ +# AngelaMos | 2026 +# config.yaml - Default configuration + +app: + name: "Go Backend Template" + version: "1.0.0" + +server: + host: "0.0.0.0" + port: 8080 + read_timeout: 30s + write_timeout: 30s + idle_timeout: 120s + shutdown_timeout: 15s + +database: + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h + conn_max_idle_time: 30m + +redis: + pool_size: 10 + min_idle_conns: 5 + +jwt: + access_token_expire: 15m + refresh_token_expire: 168h + issuer: "go-backend" + +rate_limit: + requests: 100 + window: 1m + burst: 20 + +cors: + allowed_origins: + - "http://localhost:3000" + - "http://localhost:3420" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "PATCH" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-Request-ID" + allow_credentials: true + max_age: 300 diff --git a/stacks/go-react/go-backend/dev.compose.yml b/stacks/go-react/go-backend/dev.compose.yml new file mode 100644 index 0000000..26bfa45 --- /dev/null +++ b/stacks/go-react/go-backend/dev.compose.yml @@ -0,0 +1,45 @@ +# AngelaMos | 2026 +# dev.compose.yml - Development compose + +services: + postgres: + image: postgres:18-alpine + ports: + - "5447:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + volumes: + - pgdata_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6022:6379" + command: redis-server --appendonly yes + volumes: + - redisdata_dev:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + jaeger: + image: jaegertracing/all-in-one:1.54 + ports: + - "16686:16686" + - "4317:4317" + - "4318:4318" + environment: + COLLECTOR_OTLP_ENABLED: "true" + +volumes: + pgdata_dev: + redisdata_dev: diff --git a/stacks/go-react/go-backend/go.mod b/stacks/go-react/go-backend/go.mod new file mode 100644 index 0000000..e5981e9 --- /dev/null +++ b/stacks/go-react/go-backend/go.mod @@ -0,0 +1,69 @@ +module github.com/carterperez-dev/templates/go-backend + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-playground/validator/v10 v10.23.0 + github.com/go-redis/redis_rate/v10 v10.0.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/jmoiron/sqlx v1.4.0 + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/env v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.1.2 + github.com/lestrrat-go/jwx/v3 v3.0.12 + github.com/redis/go-redis/v9 v9.7.0 + go.opentelemetry.io/otel v1.33.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 + go.opentelemetry.io/otel/sdk v1.33.0 + go.opentelemetry.io/otel/trace v1.33.0 + golang.org/x/crypto v0.43.0 + golang.org/x/time v0.14.0 + google.golang.org/grpc v1.68.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.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/knadh/koanf/maps v0.1.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/valyala/fastjson v1.6.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/net v0.45.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/protobuf v1.35.2 // indirect +) diff --git a/stacks/go-react/go-backend/go.sum b/stacks/go-react/go-backend/go.sum new file mode 100644 index 0000000..0a9a961 --- /dev/null +++ b/stacks/go-react/go-backend/go.sum @@ -0,0 +1,165 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/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.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo= +github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +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.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= +github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= +github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= +github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/stacks/go-react/go-backend/internal/admin/handler.go b/stacks/go-react/go-backend/internal/admin/handler.go new file mode 100644 index 0000000..4f75ab2 --- /dev/null +++ b/stacks/go-react/go-backend/internal/admin/handler.go @@ -0,0 +1,208 @@ +// AngelaMos | 2026 +// handler.go + +package admin + +import ( + "context" + "database/sql" + "net/http" + "runtime" + + "github.com/go-chi/chi/v5" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type AuthService interface { + InvalidateAllSessions(ctx context.Context) error +} + +type Handler struct { + dbStats func() sql.DBStats + redisStats func() *redis.PoolStats + redisPing func(ctx context.Context) error + dbPing func(ctx context.Context) error + authSvc AuthService +} + +type HandlerConfig struct { + DBStats func() sql.DBStats + RedisStats func() *redis.PoolStats + RedisPing func(ctx context.Context) error + DBPing func(ctx context.Context) error + AuthSvc AuthService +} + +func NewHandler(cfg HandlerConfig) *Handler { + return &Handler{ + dbStats: cfg.DBStats, + redisStats: cfg.RedisStats, + redisPing: cfg.RedisPing, + dbPing: cfg.DBPing, + authSvc: cfg.AuthSvc, + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator, adminOnly func(http.Handler) http.Handler, +) { + r.Route("/admin", func(r chi.Router) { + r.Use(authenticator) + r.Use(adminOnly) + + r.Get("/stats", h.GetSystemStats) + r.Get("/stats/db", h.GetDatabaseStats) + r.Get("/stats/redis", h.GetRedisStats) + r.Get("/stats/runtime", h.GetRuntimeStats) + }) +} + +func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + dbHealthy := true + if h.dbPing != nil { + if err := h.dbPing(ctx); err != nil { + dbHealthy = false + } + } + + redisHealthy := true + if h.redisPing != nil { + if err := h.redisPing(ctx); err != nil { + redisHealthy = false + } + } + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := SystemStatsResponse{ + Database: DatabaseStatus{ + Healthy: dbHealthy, + Stats: h.getDBStats(), + }, + Redis: RedisStatus{ + Healthy: redisHealthy, + Stats: h.getRedisStats(), + }, + Runtime: RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + }, + } + + core.OK(w, response) +} + +func (h *Handler) GetDatabaseStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getDBStats()) +} + +func (h *Handler) GetRedisStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getRedisStats()) +} + +func (h *Handler) GetRuntimeStats(w http.ResponseWriter, r *http.Request) { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + } + + core.OK(w, response) +} + +func (h *Handler) getDBStats() *DBPoolStats { + if h.dbStats == nil { + return nil + } + + stats := h.dbStats() + return &DBPoolStats{ + MaxOpenConnections: stats.MaxOpenConnections, + OpenConnections: stats.OpenConnections, + InUse: stats.InUse, + Idle: stats.Idle, + WaitCount: stats.WaitCount, + WaitDuration: stats.WaitDuration.String(), + MaxIdleClosed: stats.MaxIdleClosed, + MaxIdleTimeClosed: stats.MaxIdleTimeClosed, + MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} + +func (h *Handler) getRedisStats() *RedisPoolStats { + if h.redisStats == nil { + return nil + } + + stats := h.redisStats() + return &RedisPoolStats{ + Hits: stats.Hits, + Misses: stats.Misses, + Timeouts: stats.Timeouts, + TotalConns: stats.TotalConns, + IdleConns: stats.IdleConns, + StaleConns: stats.StaleConns, + } +} + +type SystemStatsResponse struct { + Database DatabaseStatus `json:"database"` + Redis RedisStatus `json:"redis"` + Runtime RuntimeStats `json:"runtime"` +} + +type DatabaseStatus struct { + Healthy bool `json:"healthy"` + Stats *DBPoolStats `json:"stats,omitempty"` +} + +type RedisStatus struct { + Healthy bool `json:"healthy"` + Stats *RedisPoolStats `json:"stats,omitempty"` +} + +type DBPoolStats struct { + MaxOpenConnections int `json:"max_open_connections"` + OpenConnections int `json:"open_connections"` + InUse int `json:"in_use"` + Idle int `json:"idle"` + WaitCount int64 `json:"wait_count"` + WaitDuration string `json:"wait_duration"` + MaxIdleClosed int64 `json:"max_idle_closed"` + MaxIdleTimeClosed int64 `json:"max_idle_time_closed"` + MaxLifetimeClosed int64 `json:"max_lifetime_closed"` +} + +type RedisPoolStats struct { + Hits uint32 `json:"hits"` + Misses uint32 `json:"misses"` + Timeouts uint32 `json:"timeouts"` + TotalConns uint32 `json:"total_conns"` + IdleConns uint32 `json:"idle_conns"` + StaleConns uint32 `json:"stale_conns"` +} + +type RuntimeStats struct { + GoVersion string `json:"go_version"` + NumGoroutine int `json:"num_goroutine"` + NumCPU int `json:"num_cpu"` + MemAlloc uint64 `json:"mem_alloc_bytes"` + MemSys uint64 `json:"mem_sys_bytes"` + NumGC uint32 `json:"num_gc"` +} diff --git a/stacks/go-react/go-backend/internal/auth/dto.go b/stacks/go-react/go-backend/internal/auth/dto.go new file mode 100644 index 0000000..9fc1cfa --- /dev/null +++ b/stacks/go-react/go-backend/internal/auth/dto.go @@ -0,0 +1,62 @@ +// AngelaMos | 2026 +// dto.go + +package auth + +import ( + "time" +) + +type LoginRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` +} + +type RegisterRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" validate:"required"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + ExpiresAt time.Time `json:"expires_at"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Tier string `json:"tier"` + CreatedAt time.Time `json:"created_at"` +} + +type AuthResponse struct { + User UserResponse `json:"user"` + Tokens TokenResponse `json:"tokens"` +} + +type SessionInfo struct { + ID string `json:"id"` + UserAgent string `json:"user_agent"` + IPAddress string `json:"ip_address"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type SessionsResponse struct { + Sessions []SessionInfo `json:"sessions"` +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8,max=128"` +} diff --git a/stacks/go-react/go-backend/internal/auth/entity.go b/stacks/go-react/go-backend/internal/auth/entity.go new file mode 100644 index 0000000..8ce8a4e --- /dev/null +++ b/stacks/go-react/go-backend/internal/auth/entity.go @@ -0,0 +1,47 @@ +// AngelaMos | 2026 +// entity.go + +package auth + +import ( + "time" +) + +type RefreshToken struct { + ID string `db:"id"` + UserID string `db:"user_id"` + TokenHash string `db:"token_hash"` + FamilyID string `db:"family_id"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` + IsUsed bool `db:"is_used"` + UsedAt *time.Time `db:"used_at"` + RevokedAt *time.Time `db:"revoked_at"` + ReplacedByID *string `db:"replaced_by_id"` + UserAgent string `db:"user_agent"` + IPAddress string `db:"ip_address"` +} + +func (t *RefreshToken) IsExpired() bool { + return time.Now().After(t.ExpiresAt) +} + +func (t *RefreshToken) IsRevoked() bool { + return t.RevokedAt != nil +} + +func (t *RefreshToken) IsValid() bool { + return !t.IsExpired() && !t.IsRevoked() && !t.IsUsed +} + +func (t *RefreshToken) MarkAsUsed(replacedByID string) { + now := time.Now() + t.IsUsed = true + t.UsedAt = &now + t.ReplacedByID = &replacedByID +} + +func (t *RefreshToken) Revoke() { + now := time.Now() + t.RevokedAt = &now +} diff --git a/stacks/go-react/go-backend/internal/auth/handler.go b/stacks/go-react/go-backend/internal/auth/handler.go new file mode 100644 index 0000000..16b3b6b --- /dev/null +++ b/stacks/go-react/go-backend/internal/auth/handler.go @@ -0,0 +1,316 @@ +// AngelaMos | 2026 +// handler.go + +package auth + +import ( + "encoding/json" + "errors" + "net" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" +) + +type Handler struct { + service *Service + validator *validator.Validate +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/auth", func(r chi.Router) { + r.Post("/login", h.Login) + r.Post("/register", h.Register) + r.Post("/refresh", h.Refresh) + + r.Group(func(r chi.Router) { + r.Use(authenticator) + r.Get("/me", h.GetMe) + r.Post("/logout", h.Logout) + r.Post("/logout-all", h.LogoutAll) + r.Get("/sessions", h.GetSessions) + r.Delete("/sessions/{sessionID}", h.RevokeSession) + r.Post("/change-password", h.ChangePassword) + }) + }) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := extractIPAddress(r) + + resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress) + if err != nil { + if errors.Is(err, ErrInvalidCredentials) { + core.JSONError( + w, + core.UnauthorizedError("invalid email or password"), + ) + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, resp) +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := extractIPAddress(r) + + resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress) + if err != nil { + if errors.Is(err, ErrEmailExists) { + core.JSONError(w, core.DuplicateError("email")) + return + } + core.InternalServerError(w, err) + return + } + + core.Created(w, resp) +} + +func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := extractIPAddress(r) + + resp, err := h.service.Refresh( + r.Context(), + req.RefreshToken, + userAgent, + ipAddress, + ) + if err != nil { + if errors.Is(err, ErrTokenReuse) { + core.JSONError(w, core.NewAppError( + core.ErrTokenRevoked, + "security alert: token reuse detected, all sessions revoked", + http.StatusUnauthorized, + "TOKEN_REUSE_DETECTED", + )) + return + } + if errors.Is(err, core.ErrTokenExpired) { + core.JSONError(w, core.TokenExpiredError()) + return + } + if errors.Is(err, core.ErrTokenRevoked) { + core.JSONError(w, core.TokenRevokedError()) + return + } + if errors.Is(err, core.ErrTokenInvalid) { + core.JSONError(w, core.TokenInvalidError()) + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, resp) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.service.Logout(r.Context(), req.RefreshToken, userID); err != nil { + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "cannot revoke another user's token") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + if err := h.service.LogoutAll(r.Context(), userID); err != nil { + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetSessions(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + sessions, err := h.service.GetActiveSessions(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.OK(w, SessionsResponse{Sessions: sessions}) +} + +func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + sessionID := chi.URLParam(r, "sessionID") + if sessionID == "" { + core.BadRequest(w, "session ID required") + return + } + + if err := h.service.RevokeSession(r.Context(), userID, sessionID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "session") + return + } + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "cannot revoke another user's session") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + var req ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + if err := h.service.ChangePassword(r.Context(), userID, req.CurrentPassword, req.NewPassword); err != nil { + if errors.Is(err, ErrInvalidCredentials) { + core.JSONError( + w, + core.UnauthorizedError("current password is incorrect"), + ) + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + user, err := h.service.GetCurrentUser(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, user) +} + +func extractIPAddress(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + return strings.TrimSpace(ips[len(ips)-1]) + } + + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + + return ip +} diff --git a/stacks/go-react/go-backend/internal/auth/jwt.go b/stacks/go-react/go-backend/internal/auth/jwt.go new file mode 100644 index 0000000..026cf05 --- /dev/null +++ b/stacks/go-react/go-backend/internal/auth/jwt.go @@ -0,0 +1,295 @@ +// AngelaMos | 2026 +// jwt.go + +package auth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + + "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" +) + +type JWTManager struct { + privateKey jwk.Key + publicKey jwk.Key + publicJWKS jwk.Set + config config.JWTConfig +} + +func NewJWTManager(cfg config.JWTConfig) (*JWTManager, error) { + privateKeyPEM, err := os.ReadFile(cfg.PrivateKeyPath) + if err != nil { + return nil, fmt.Errorf("read private key: %w", err) + } + + privateKey, err := jwk.ParseKey(privateKeyPEM, jwk.WithPEM(true)) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + if setErr := privateKey.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil { + return nil, fmt.Errorf("set algorithm: %w", setErr) + } + + keyID := uuid.New().String()[:8] + if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil { + return nil, fmt.Errorf("set key id: %w", setErr) + } + + publicKey, err := privateKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("derive public key: %w", err) + } + + if setErr := publicKey.Set(jwk.KeyUsageKey, "sig"); setErr != nil { + return nil, fmt.Errorf("set key usage: %w", setErr) + } + + publicJWKS := jwk.NewSet() + if addErr := publicJWKS.AddKey(publicKey); addErr != nil { + return nil, fmt.Errorf("add key to set: %w", addErr) + } + + return &JWTManager{ + privateKey: privateKey, + publicKey: publicKey, + publicJWKS: publicJWKS, + config: cfg, + }, nil +} + +func GenerateKeyPair(privateKeyPath, publicKeyPath string) error { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate key: %w", err) + } + + jwkPrivate, err := jwk.Import(privateKey) + if err != nil { + return fmt.Errorf("import private key: %w", err) + } + + keyID := uuid.New().String()[:8] + if setErr := jwkPrivate.Set(jwk.KeyIDKey, keyID); setErr != nil { + return fmt.Errorf("set key id: %w", setErr) + } + if setErr := jwkPrivate.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil { + return fmt.Errorf("set algorithm: %w", setErr) + } + + privatePEM, err := jwk.Pem(jwkPrivate) + if err != nil { + return fmt.Errorf("encode private key: %w", err) + } + + if writeErr := os.WriteFile(privateKeyPath, privatePEM, 0o600); writeErr != nil { + return fmt.Errorf("write private key: %w", writeErr) + } + + jwkPublic, err := jwkPrivate.PublicKey() + if err != nil { + return fmt.Errorf("derive public key: %w", err) + } + + publicPEM, err := jwk.Pem(jwkPublic) + if err != nil { + return fmt.Errorf("encode public key: %w", err) + } + + //nolint:gosec // G306: public key is intentionally world-readable + if writeErr := os.WriteFile(publicKeyPath, publicPEM, 0o644); writeErr != nil { + return fmt.Errorf("write public key: %w", writeErr) + } + + return nil +} + +type AccessTokenClaims struct { + UserID string `json:"sub"` + Role string `json:"role"` + Tier string `json:"tier"` + TokenVersion int `json:"token_version"` +} + +func (m *JWTManager) CreateAccessToken( + claims AccessTokenClaims, +) (string, error) { + now := time.Now() + + token, err := jwt.NewBuilder(). + JwtID(uuid.New().String()). + Issuer(m.config.Issuer). + Audience([]string{m.config.Audience}). + Subject(claims.UserID). + IssuedAt(now). + Expiration(now.Add(m.config.AccessTokenExpire)). + NotBefore(now). + Claim("role", claims.Role). + Claim("tier", claims.Tier). + Claim("token_version", claims.TokenVersion). + Claim("type", "access"). + Build() + if err != nil { + return "", fmt.Errorf("build token: %w", err) + } + + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), m.privateKey)) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + + return string(signed), nil +} + +func (m *JWTManager) VerifyAccessToken( + ctx context.Context, + tokenString string, +) (*middleware.AccessTokenClaims, error) { + token, err := jwt.Parse( + []byte(tokenString), + jwt.WithKey(jwa.ES256(), m.publicKey), + jwt.WithValidate(true), + jwt.WithIssuer(m.config.Issuer), + jwt.WithAudience(m.config.Audience), + ) + if err != nil { + if isTokenExpiredError(err) { + return nil, fmt.Errorf("verify token: %w", core.ErrTokenExpired) + } + return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid) + } + + var tokenType string + if err := token.Get("type", &tokenType); err != nil || + tokenType != "access" { + return nil, fmt.Errorf( + "verify token: invalid token type: %w", + core.ErrTokenInvalid, + ) + } + + subject, ok := token.Subject() + if !ok || subject == "" { + return nil, fmt.Errorf( + "verify token: missing subject: %w", + core.ErrTokenInvalid, + ) + } + + var roleStr string + if err := token.Get("role", &roleStr); err != nil { + return nil, fmt.Errorf( + "verify token: missing role claim: %w", + core.ErrTokenInvalid, + ) + } + + var tierStr string + if err := token.Get("tier", &tierStr); err != nil { + return nil, fmt.Errorf( + "verify token: missing tier claim: %w", + core.ErrTokenInvalid, + ) + } + + var versionFloat float64 + if err := token.Get("token_version", &versionFloat); err != nil { + return nil, fmt.Errorf( + "verify token: missing token_version claim: %w", + core.ErrTokenInvalid, + ) + } + + return &middleware.AccessTokenClaims{ + UserID: subject, + Role: roleStr, + Tier: tierStr, + TokenVersion: int(versionFloat), + }, nil +} + +func isTokenExpiredError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "exp") && + strings.Contains(errStr, "not satisfied") +} + +func (m *JWTManager) GetJWKSHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + + if err := json.NewEncoder(w).Encode(m.publicJWKS); err != nil { + http.Error( + w, + "Internal Server Error", + http.StatusInternalServerError, + ) + return + } + } +} + +func (m *JWTManager) GetPublicKey() jwk.Key { + return m.publicKey +} + +func (m *JWTManager) GetKeyID() string { + var kid string + //nolint:errcheck // key ID always set during NewJWTManager init + _ = m.privateKey.Get(jwk.KeyIDKey, &kid) + return kid +} + +type RefreshTokenData struct { + Token string + Hash string + ExpiresAt time.Time + FamilyID string +} + +func (m *JWTManager) CreateRefreshToken( + userID, familyID string, +) (*RefreshTokenData, error) { + token, err := core.GenerateRefreshToken() + if err != nil { + return nil, fmt.Errorf("generate refresh token: %w", err) + } + + hash := core.HashToken(token) + expiresAt := time.Now().Add(m.config.RefreshTokenExpire) + + if familyID == "" { + familyID = uuid.New().String() + } + + return &RefreshTokenData{ + Token: token, + Hash: hash, + ExpiresAt: expiresAt, + FamilyID: familyID, + }, nil +} + +func (m *JWTManager) VerifyRefreshTokenHash(token, storedHash string) bool { + return core.CompareTokenHash(token, storedHash) +} diff --git a/stacks/go-react/go-backend/internal/auth/repository.go b/stacks/go-react/go-backend/internal/auth/repository.go new file mode 100644 index 0000000..2abb036 --- /dev/null +++ b/stacks/go-react/go-backend/internal/auth/repository.go @@ -0,0 +1,236 @@ +// AngelaMos | 2026 +// repository.go + +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type Repository interface { + Create(ctx context.Context, token *RefreshToken) error + FindByHash(ctx context.Context, tokenHash string) (*RefreshToken, error) + FindByID(ctx context.Context, id string) (*RefreshToken, error) + MarkAsUsed(ctx context.Context, id, replacedByID string) error + RevokeByID(ctx context.Context, id string) error + RevokeByFamilyID(ctx context.Context, familyID string) error + RevokeAllForUser(ctx context.Context, userID string) error + GetActiveSessionsForUser( + ctx context.Context, + userID string, + ) ([]RefreshToken, error) + DeleteExpired(ctx context.Context) (int64, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) Create(ctx context.Context, token *RefreshToken) error { + query := ` + INSERT INTO refresh_tokens ( + id, user_id, token_hash, family_id, expires_at, + user_agent, ip_address + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + RETURNING created_at` + + err := r.db.GetContext(ctx, &token.CreatedAt, query, + token.ID, + token.UserID, + token.TokenHash, + token.FamilyID, + token.ExpiresAt, + token.UserAgent, + token.IPAddress, + ) + if err != nil { + return fmt.Errorf("create refresh token: %w", err) + } + + return nil +} + +func (r *repository) FindByHash( + ctx context.Context, + tokenHash string, +) (*RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE token_hash = $1` + + var token RefreshToken + err := r.db.GetContext(ctx, &token, query, tokenHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("find refresh token: %w", err) + } + + return &token, nil +} + +func (r *repository) FindByID( + ctx context.Context, + id string, +) (*RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE id = $1` + + var token RefreshToken + err := r.db.GetContext(ctx, &token, query, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("find refresh token: %w", err) + } + + return &token, nil +} + +func (r *repository) MarkAsUsed( + ctx context.Context, + id, replacedByID string, +) error { + query := ` + UPDATE refresh_tokens + SET is_used = true, used_at = NOW(), replaced_by_id = $2 + WHERE id = $1 AND is_used = false` + + result, err := r.db.ExecContext(ctx, query, id, replacedByID) + if err != nil { + return fmt.Errorf("mark refresh token as used: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("mark refresh token as used: %w", err) + } + + if rows == 0 { + return fmt.Errorf("mark refresh token as used: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) RevokeByID(ctx context.Context, id string) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE id = $1 AND revoked_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + + if rows == 0 { + return fmt.Errorf("revoke refresh token: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) RevokeByFamilyID( + ctx context.Context, + familyID string, +) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE family_id = $1 AND revoked_at IS NULL` + + _, err := r.db.ExecContext(ctx, query, familyID) + if err != nil { + return fmt.Errorf("revoke token family: %w", err) + } + + return nil +} + +func (r *repository) RevokeAllForUser( + ctx context.Context, + userID string, +) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL` + + _, err := r.db.ExecContext(ctx, query, userID) + if err != nil { + return fmt.Errorf("revoke all user tokens: %w", err) + } + + return nil +} + +func (r *repository) GetActiveSessionsForUser( + ctx context.Context, + userID string, +) ([]RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE user_id = $1 + AND revoked_at IS NULL + AND is_used = false + AND expires_at > NOW() + ORDER BY created_at DESC` + + var tokens []RefreshToken + err := r.db.SelectContext(ctx, &tokens, query, userID) + if err != nil { + return nil, fmt.Errorf("get active sessions: %w", err) + } + + return tokens, nil +} + +func (r *repository) DeleteExpired(ctx context.Context) (int64, error) { + query := ` + DELETE FROM refresh_tokens + WHERE expires_at < $1` + + cutoff := time.Now().Add(-24 * time.Hour) + + result, err := r.db.ExecContext(ctx, query, cutoff) + if err != nil { + return 0, fmt.Errorf("delete expired tokens: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("delete expired tokens: %w", err) + } + + return rows, nil +} diff --git a/stacks/go-react/go-backend/internal/auth/service.go b/stacks/go-react/go-backend/internal/auth/service.go new file mode 100644 index 0000000..3629051 --- /dev/null +++ b/stacks/go-react/go-backend/internal/auth/service.go @@ -0,0 +1,411 @@ +// AngelaMos | 2026 +// service.go + +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrTokenReuse = errors.New("token reuse detected") + ErrEmailExists = errors.New("email already exists") +) + +type UserInfo struct { + ID string + Email string + Name string + PasswordHash string + Role string + Tier string + TokenVersion int +} + +type UserProvider interface { + GetByEmail(ctx context.Context, email string) (*UserInfo, error) + GetByID(ctx context.Context, id string) (*UserInfo, error) + Create( + ctx context.Context, + email, passwordHash, name string, + ) (*UserInfo, error) + IncrementTokenVersion(ctx context.Context, userID string) error + UpdatePassword(ctx context.Context, userID, passwordHash string) error +} + +type Service struct { + repo Repository + jwt *JWTManager + userProvider UserProvider + redis *redis.Client + blacklistTTL time.Duration +} + +func NewService( + repo Repository, + jwt *JWTManager, + userProvider UserProvider, + redisClient *redis.Client, +) *Service { + return &Service{ + repo: repo, + jwt: jwt, + userProvider: userProvider, + redis: redisClient, + blacklistTTL: 15 * time.Minute, + } +} + +func (s *Service) Login( + ctx context.Context, + req LoginRequest, + userAgent, ipAddress string, +) (*AuthResponse, error) { + user, err := s.userProvider.GetByEmail(ctx, req.Email) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + //nolint:errcheck // timing attack prevention - always verify to prevent enumeration + _, _, _ = core.VerifyPasswordTimingSafe(req.Password, nil) + return nil, ErrInvalidCredentials + } + return nil, fmt.Errorf("get user: %w", err) + } + + valid, newHash, err := core.VerifyPasswordTimingSafe( + req.Password, + &user.PasswordHash, + ) + if err != nil { + return nil, fmt.Errorf("verify password: %w", err) + } + + if !valid { + return nil, ErrInvalidCredentials + } + + if newHash != "" { + //nolint:errcheck // best-effort rehash upgrade + _ = s.userProvider.UpdatePassword(ctx, user.ID, newHash) + } + + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) +} + +func (s *Service) Register( + ctx context.Context, + req RegisterRequest, + userAgent, ipAddress string, +) (*AuthResponse, error) { + passwordHash, err := core.HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user, err := s.userProvider.Create(ctx, req.Email, passwordHash, req.Name) + if err != nil { + if errors.Is(err, core.ErrDuplicateKey) { + return nil, ErrEmailExists + } + return nil, fmt.Errorf("create user: %w", err) + } + + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) +} + +func (s *Service) Refresh( + ctx context.Context, + refreshToken, userAgent, ipAddress string, +) (*AuthResponse, error) { + tokenHash := core.HashToken(refreshToken) + + storedToken, err := s.repo.FindByHash(ctx, tokenHash) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil, fmt.Errorf("refresh: %w", core.ErrTokenInvalid) + } + return nil, fmt.Errorf("find token: %w", err) + } + + if storedToken.IsUsed { + //nolint:errcheck // security revocation continues regardless + _ = s.repo.RevokeByFamilyID(ctx, storedToken.FamilyID) + return nil, ErrTokenReuse + } + + if !storedToken.IsValid() { + if storedToken.IsRevoked() { + return nil, fmt.Errorf("refresh: %w", core.ErrTokenRevoked) + } + return nil, fmt.Errorf("refresh: %w", core.ErrTokenExpired) + } + + user, err := s.userProvider.GetByID(ctx, storedToken.UserID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + return s.createAuthResponse( + ctx, + user, + userAgent, + ipAddress, + storedToken.FamilyID, + &storedToken.ID, + ) +} + +func (s *Service) Logout( + ctx context.Context, + refreshToken, userID string, +) error { + tokenHash := core.HashToken(refreshToken) + + storedToken, err := s.repo.FindByHash(ctx, tokenHash) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil + } + return fmt.Errorf("find token: %w", err) + } + + if storedToken.UserID != userID { + return fmt.Errorf("logout: %w", core.ErrForbidden) + } + + if err := s.repo.RevokeByID(ctx, storedToken.ID); err != nil && + !errors.Is(err, core.ErrNotFound) { + return fmt.Errorf("revoke token: %w", err) + } + + return nil +} + +func (s *Service) LogoutAll(ctx context.Context, userID string) error { + if err := s.repo.RevokeAllForUser(ctx, userID); err != nil { + return fmt.Errorf("revoke all tokens: %w", err) + } + + if err := s.userProvider.IncrementTokenVersion(ctx, userID); err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + return nil +} + +func (s *Service) RevokeAccessToken( + ctx context.Context, + jti string, + expiresAt time.Time, +) error { + key := "blacklist:" + jti + ttl := time.Until(expiresAt) + + if ttl <= 0 { + return nil + } + + if err := s.redis.Set(ctx, key, "1", ttl).Err(); err != nil { + return fmt.Errorf("blacklist token: %w", err) + } + + return nil +} + +func (s *Service) IsAccessTokenBlacklisted( + ctx context.Context, + jti string, +) (bool, error) { + key := "blacklist:" + jti + + exists, err := s.redis.Exists(ctx, key).Result() + if err != nil { + return false, fmt.Errorf("check blacklist: %w", err) + } + + return exists > 0, nil +} + +func (s *Service) GetActiveSessions( + ctx context.Context, + userID string, +) ([]SessionInfo, error) { + tokens, err := s.repo.GetActiveSessionsForUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get sessions: %w", err) + } + + sessions := make([]SessionInfo, 0, len(tokens)) + for _, t := range tokens { + sessions = append(sessions, SessionInfo{ + ID: t.ID, + UserAgent: t.UserAgent, + IPAddress: t.IPAddress, + CreatedAt: t.CreatedAt, + ExpiresAt: t.ExpiresAt, + }) + } + + return sessions, nil +} + +func (s *Service) RevokeSession( + ctx context.Context, + userID, sessionID string, +) error { + token, err := s.repo.FindByID(ctx, sessionID) + if err != nil { + return fmt.Errorf("find session: %w", err) + } + + if token.UserID != userID { + return fmt.Errorf("revoke session: %w", core.ErrForbidden) + } + + if err := s.repo.RevokeByID(ctx, sessionID); err != nil { + return fmt.Errorf("revoke session: %w", err) + } + + return nil +} + +func (s *Service) ChangePassword( + ctx context.Context, + userID, currentPassword, newPassword string, +) error { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + valid, _, err := core.VerifyPasswordWithRehash( + currentPassword, + user.PasswordHash, + ) + if err != nil { + return fmt.Errorf("verify password: %w", err) + } + + if !valid { + return ErrInvalidCredentials + } + + newHash, err := core.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + if err := s.userProvider.UpdatePassword(ctx, userID, newHash); err != nil { + return fmt.Errorf("update password: %w", err) + } + + if err := s.LogoutAll(ctx, userID); err != nil { + return fmt.Errorf("logout all: %w", err) + } + + return nil +} + +func (s *Service) ValidateTokenVersion( + ctx context.Context, + userID string, + tokenVersion int, +) error { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + if tokenVersion < user.TokenVersion { + return fmt.Errorf("validate token version: %w", core.ErrTokenRevoked) + } + + return nil +} + +func (s *Service) GetCurrentUser( + ctx context.Context, + userID string, +) (*UserResponse, error) { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + return &UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + Tier: user.Tier, + }, nil +} + +func (s *Service) createAuthResponse( + ctx context.Context, + user *UserInfo, + userAgent, ipAddress, familyID string, + oldTokenID *string, +) (*AuthResponse, error) { + accessToken, err := s.jwt.CreateAccessToken(AccessTokenClaims{ + UserID: user.ID, + Role: user.Role, + Tier: user.Tier, + TokenVersion: user.TokenVersion, + }) + if err != nil { + return nil, fmt.Errorf("create access token: %w", err) + } + + refreshData, err := s.jwt.CreateRefreshToken(user.ID, familyID) + if err != nil { + return nil, fmt.Errorf("create refresh token: %w", err) + } + + newTokenID := uuid.New().String() + + refreshTokenEntity := &RefreshToken{ + ID: newTokenID, + UserID: user.ID, + TokenHash: refreshData.Hash, + FamilyID: refreshData.FamilyID, + ExpiresAt: refreshData.ExpiresAt, + UserAgent: userAgent, + IPAddress: ipAddress, + } + + if err := s.repo.Create(ctx, refreshTokenEntity); err != nil { + return nil, fmt.Errorf("store refresh token: %w", err) + } + + if oldTokenID != nil { + //nolint:errcheck // best-effort token chain tracking + _ = s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID) + } + + return &AuthResponse{ + User: UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + Tier: user.Tier, + CreatedAt: time.Now(), + }, + Tokens: TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshData.Token, + TokenType: "Bearer", + ExpiresIn: int(15 * time.Minute / time.Second), + ExpiresAt: time.Now().Add(15 * time.Minute), + }, + }, nil +} diff --git a/stacks/go-react/go-backend/internal/config/config.go b/stacks/go-react/go-backend/internal/config/config.go new file mode 100644 index 0000000..ff266e5 --- /dev/null +++ b/stacks/go-react/go-backend/internal/config/config.go @@ -0,0 +1,302 @@ +// AngelaMos | 2026 +// config.go + +package config + +import ( + "fmt" + "sync" + "time" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +type Config struct { + App AppConfig `koanf:"app"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + Redis RedisConfig `koanf:"redis"` + JWT JWTConfig `koanf:"jwt"` + RateLimit RateLimitConfig `koanf:"rate_limit"` + CORS CORSConfig `koanf:"cors"` + Log LogConfig `koanf:"log"` + Otel OtelConfig `koanf:"otel"` +} + +type AppConfig struct { + Name string `koanf:"name"` + Version string `koanf:"version"` + Environment string `koanf:"environment"` +} + +type ServerConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` +} + +type DatabaseConfig struct { + URL string `koanf:"url"` + MaxOpenConns int `koanf:"max_open_conns"` + MaxIdleConns int `koanf:"max_idle_conns"` + ConnMaxLifetime time.Duration `koanf:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `koanf:"conn_max_idle_time"` +} + +type RedisConfig struct { + URL string `koanf:"url"` + PoolSize int `koanf:"pool_size"` + MinIdleConns int `koanf:"min_idle_conns"` +} + +type JWTConfig struct { + PrivateKeyPath string `koanf:"private_key_path"` + PublicKeyPath string `koanf:"public_key_path"` + AccessTokenExpire time.Duration `koanf:"access_token_expire"` + RefreshTokenExpire time.Duration `koanf:"refresh_token_expire"` + Issuer string `koanf:"issuer"` + Audience string `koanf:"audience"` +} + +type RateLimitConfig struct { + Requests int `koanf:"requests"` + Window time.Duration `koanf:"window"` + Burst int `koanf:"burst"` +} + +type CORSConfig struct { + AllowedOrigins []string `koanf:"allowed_origins"` + AllowedMethods []string `koanf:"allowed_methods"` + AllowedHeaders []string `koanf:"allowed_headers"` + AllowCredentials bool `koanf:"allow_credentials"` + MaxAge int `koanf:"max_age"` +} + +type LogConfig struct { + Level string `koanf:"level"` + Format string `koanf:"format"` +} + +type OtelConfig struct { + Endpoint string `koanf:"endpoint"` + ServiceName string `koanf:"service_name"` + Enabled bool `koanf:"enabled"` + Insecure bool `koanf:"insecure"` + SampleRate float64 `koanf:"sample_rate"` +} + +var ( + cfg *Config + once sync.Once +) + +func Load(configPath string) (*Config, error) { + var loadErr error + + once.Do(func() { + k := koanf.New(".") + + if err := loadDefaults(k); err != nil { + loadErr = fmt.Errorf("load defaults: %w", err) + return + } + + if configPath != "" { + if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + loadErr = fmt.Errorf("load config file: %w", err) + return + } + } + + if err := k.Load(env.Provider("", ".", envKeyReplacer), nil); err != nil { + loadErr = fmt.Errorf("load env vars: %w", err) + return + } + + cfg = &Config{} + if err := k.Unmarshal("", cfg); err != nil { + loadErr = fmt.Errorf("unmarshal config: %w", err) + return + } + + if err := validate(cfg); err != nil { + loadErr = fmt.Errorf("validate config: %w", err) + return + } + }) + + if loadErr != nil { + return nil, loadErr + } + + return cfg, nil +} + +func Get() *Config { + if cfg == nil { + panic("config not loaded: call Load() first") + } + return cfg +} + +func loadDefaults(k *koanf.Koanf) error { + defaults := map[string]any{ + "app.name": "Go Backend", + "app.version": "1.0.0", + "app.environment": "development", + + "server.host": "0.0.0.0", + "server.port": 8080, + "server.read_timeout": "30s", + "server.write_timeout": "30s", + "server.idle_timeout": "120s", + "server.shutdown_timeout": "15s", + + "database.max_open_conns": 25, + "database.max_idle_conns": 5, + "database.conn_max_lifetime": "1h", + "database.conn_max_idle_time": "30m", + + "redis.pool_size": 10, + "redis.min_idle_conns": 5, + + "jwt.access_token_expire": "15m", + "jwt.refresh_token_expire": "168h", + "jwt.issuer": "go-backend", + "jwt.audience": "go-backend-api", + "jwt.private_key_path": "keys/private.pem", + "jwt.public_key_path": "keys/public.pem", + + "rate_limit.requests": 100, + "rate_limit.window": "1m", + "rate_limit.burst": 20, + + "cors.allowed_origins": []string{"http://localhost:3000"}, + "cors.allowed_methods": []string{ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + }, + "cors.allowed_headers": []string{ + "Accept", + "Authorization", + "Content-Type", + "X-Request-ID", + }, + "cors.allow_credentials": true, + "cors.max_age": 300, + + "log.level": "info", + "log.format": "json", + + "otel.enabled": false, + "otel.insecure": true, + "otel.sample_rate": 0.1, + "otel.service_name": "go-backend", + } + + for key, value := range defaults { + if err := k.Set(key, value); err != nil { + return fmt.Errorf("set default %s: %w", key, err) + } + } + + return nil +} + +var envKeyMap = map[string]string{ + "DATABASE_URL": "database.url", + "REDIS_URL": "redis.url", + "ENVIRONMENT": "app.environment", + "HOST": "server.host", + "PORT": "server.port", + "LOG_LEVEL": "log.level", + "LOG_FORMAT": "log.format", + "JWT_PRIVATE_KEY_PATH": "jwt.private_key_path", + "JWT_PUBLIC_KEY_PATH": "jwt.public_key_path", + "JWT_ACCESS_TOKEN_EXPIRE": "jwt.access_token_expire", + "JWT_REFRESH_TOKEN_EXPIRE": "jwt.refresh_token_expire", + "JWT_ISSUER": "jwt.issuer", + "JWT_AUDIENCE": "jwt.audience", + "RATE_LIMIT_REQUESTS": "rate_limit.requests", + "RATE_LIMIT_WINDOW": "rate_limit.window", + "RATE_LIMIT_BURST": "rate_limit.burst", + "OTEL_ENDPOINT": "otel.endpoint", + "OTEL_EXPORTER_OTLP_ENDPOINT": "otel.endpoint", + "OTEL_SERVICE_NAME": "otel.service_name", + "OTEL_ENABLED": "otel.enabled", + "OTEL_INSECURE": "otel.insecure", + "OTEL_SAMPLE_RATE": "otel.sample_rate", +} + +func envKeyReplacer(s string) string { + if mapped, ok := envKeyMap[s]; ok { + return mapped + } + return "" +} + +func validate(c *Config) error { + if c.Database.URL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + + if c.Redis.URL == "" { + return fmt.Errorf("REDIS_URL is required") + } + + if c.JWT.PrivateKeyPath == "" { + return fmt.Errorf("JWT_PRIVATE_KEY_PATH is required") + } + + if c.JWT.PublicKeyPath == "" { + return fmt.Errorf("JWT_PUBLIC_KEY_PATH is required") + } + + if c.CORS.AllowCredentials { + for _, origin := range c.CORS.AllowedOrigins { + if origin == "*" { + return fmt.Errorf( + "CORS wildcard '*' cannot be used with AllowCredentials", + ) + } + } + } + + if c.App.Environment == "production" { + if c.Otel.Enabled && c.Otel.Insecure { + return fmt.Errorf("OTEL_INSECURE must be false in production") + } + } + + if c.Server.ReadTimeout <= 0 { + return fmt.Errorf("server.read_timeout must be positive") + } + + if c.Server.WriteTimeout <= 0 { + return fmt.Errorf("server.write_timeout must be positive") + } + + return nil +} + +func (c *Config) IsProduction() bool { + return c.App.Environment == "production" +} + +func (c *Config) IsDevelopment() bool { + return c.App.Environment == "development" +} + +func (s *ServerConfig) Address() string { + return fmt.Sprintf("%s:%d", s.Host, s.Port) +} diff --git a/stacks/go-react/go-backend/internal/core/database.go b/stacks/go-react/go-backend/internal/core/database.go new file mode 100644 index 0000000..766af7f --- /dev/null +++ b/stacks/go-react/go-backend/internal/core/database.go @@ -0,0 +1,145 @@ +// AngelaMos | 2026 +// database.go + +package core + +import ( + "context" + "database/sql" + "fmt" + "math/rand/v2" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +type Database struct { + DB *sqlx.DB +} + +func NewDatabase( + ctx context.Context, + cfg config.DatabaseConfig, +) (*Database, error) { + db, err := sqlx.ConnectContext(ctx, "pgx", cfg.URL) + if err != nil { + return nil, fmt.Errorf("connect to database: %w", err) + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(jitteredDuration(cfg.ConnMaxLifetime)) + db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := db.PingContext(pingCtx); err != nil { + _ = db.Close() //nolint:errcheck // cleanup on connection failure + return nil, fmt.Errorf("ping database: %w", err) + } + + return &Database{DB: db}, nil +} + +func (d *Database) Close() error { + if d.DB != nil { + return d.DB.Close() + } + return nil +} + +func (d *Database) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := d.DB.PingContext(pingCtx); err != nil { + return fmt.Errorf("database ping failed: %w", err) + } + + return nil +} + +func (d *Database) Stats() sql.DBStats { + return d.DB.Stats() +} + +type DBTX interface { + sqlx.ExtContext + sqlx.ExecerContext + GetContext(ctx context.Context, dest any, query string, args ...any) error + SelectContext( + ctx context.Context, + dest any, + query string, + args ...any, + ) error +} + +func InTx(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error { + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func InTxWithOptions( + ctx context.Context, + db *sqlx.DB, + opts *sql.TxOptions, + fn func(tx *sqlx.Tx) error, +) error { + tx, err := db.BeginTxx(ctx, opts) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func jitteredDuration(base time.Duration) time.Duration { + //nolint:gosec // G404: non-security-sensitive jitter for connection pool + jitter := time.Duration(rand.Int64N(int64(base / 7))) + return base + jitter +} diff --git a/stacks/go-react/go-backend/internal/core/errors.go b/stacks/go-react/go-backend/internal/core/errors.go new file mode 100644 index 0000000..b478a6e --- /dev/null +++ b/stacks/go-react/go-backend/internal/core/errors.go @@ -0,0 +1,169 @@ +// AngelaMos | 2026 +// errors.go + +package core + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + ErrNotFound = errors.New("resource not found") + ErrDuplicateKey = errors.New("duplicate key violation") + ErrForeignKey = errors.New("foreign key violation") + ErrInvalidInput = errors.New("invalid input") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrInternal = errors.New("internal server error") + ErrConflict = errors.New("resource conflict") + ErrRateLimited = errors.New("rate limit exceeded") + ErrTokenExpired = errors.New("token expired") + ErrTokenInvalid = errors.New("token invalid") + ErrTokenRevoked = errors.New("token revoked") +) + +type AppError struct { + Err error `json:"-"` + Message string `json:"message"` + StatusCode int `json:"-"` + Code string `json:"code"` +} + +func (e *AppError) Error() string { + if e.Message != "" { + return e.Message + } + if e.Err != nil { + return e.Err.Error() + } + return "unknown error" +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +func NewAppError( + err error, + message string, + statusCode int, + code string, +) *AppError { + return &AppError{ + Err: err, + Message: message, + StatusCode: statusCode, + Code: code, + } +} + +func NotFoundError(resource string) *AppError { + return &AppError{ + Err: ErrNotFound, + Message: fmt.Sprintf("%s not found", resource), + StatusCode: http.StatusNotFound, + Code: "NOT_FOUND", + } +} + +func DuplicateError(field string) *AppError { + return &AppError{ + Err: ErrDuplicateKey, + Message: fmt.Sprintf("%s already exists", field), + StatusCode: http.StatusConflict, + Code: "DUPLICATE", + } +} + +func ValidationError(message string) *AppError { + return &AppError{ + Err: ErrInvalidInput, + Message: message, + StatusCode: http.StatusBadRequest, + Code: "VALIDATION_ERROR", + } +} + +func UnauthorizedError(message string) *AppError { + if message == "" { + message = "authentication required" + } + return &AppError{ + Err: ErrUnauthorized, + Message: message, + StatusCode: http.StatusUnauthorized, + Code: "UNAUTHORIZED", + } +} + +func ForbiddenError(message string) *AppError { + if message == "" { + message = "access denied" + } + return &AppError{ + Err: ErrForbidden, + Message: message, + StatusCode: http.StatusForbidden, + Code: "FORBIDDEN", + } +} + +func InternalError(err error) *AppError { + return &AppError{ + Err: err, + Message: "internal server error", + StatusCode: http.StatusInternalServerError, + Code: "INTERNAL_ERROR", + } +} + +func RateLimitError() *AppError { + return &AppError{ + Err: ErrRateLimited, + Message: "too many requests", + StatusCode: http.StatusTooManyRequests, + Code: "RATE_LIMITED", + } +} + +func TokenExpiredError() *AppError { + return &AppError{ + Err: ErrTokenExpired, + Message: "token has expired", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_EXPIRED", + } +} + +func TokenInvalidError() *AppError { + return &AppError{ + Err: ErrTokenInvalid, + Message: "invalid token", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_INVALID", + } +} + +func TokenRevokedError() *AppError { + return &AppError{ + Err: ErrTokenRevoked, + Message: "token has been revoked", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_REVOKED", + } +} + +func IsAppError(err error) bool { + var appErr *AppError + return errors.As(err, &appErr) +} + +func GetAppError(err error) *AppError { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr + } + return InternalError(err) +} diff --git a/stacks/go-react/go-backend/internal/core/redis.go b/stacks/go-react/go-backend/internal/core/redis.go new file mode 100644 index 0000000..822b6cc --- /dev/null +++ b/stacks/go-react/go-backend/internal/core/redis.go @@ -0,0 +1,63 @@ +// AngelaMos | 2026 +// redis.go + +package core + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +type Redis struct { + Client *redis.Client +} + +func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) { + opts, err := redis.ParseURL(cfg.URL) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + + opts.PoolSize = cfg.PoolSize + opts.MinIdleConns = cfg.MinIdleConns + opts.PoolTimeout = 30 * time.Second + opts.ConnMaxIdleTime = 5 * time.Minute + + client := redis.NewClient(opts) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := client.Ping(pingCtx).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + + return &Redis{Client: client}, nil +} + +func (r *Redis) Close() error { + if r.Client != nil { + return r.Client.Close() + } + return nil +} + +func (r *Redis) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := r.Client.Ping(pingCtx).Err(); err != nil { + return fmt.Errorf("redis ping failed: %w", err) + } + + return nil +} + +func (r *Redis) PoolStats() *redis.PoolStats { + return r.Client.PoolStats() +} diff --git a/stacks/go-react/go-backend/internal/core/response.go b/stacks/go-react/go-backend/internal/core/response.go new file mode 100644 index 0000000..e37ac73 --- /dev/null +++ b/stacks/go-react/go-backend/internal/core/response.go @@ -0,0 +1,120 @@ +// AngelaMos | 2026 +// response.go + +package core + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +type Response struct { + Success bool `json:"success"` + Data any `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type Meta struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Total int `json:"total,omitempty"` + TotalPages int `json:"total_pages,omitempty"` +} + +func JSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: status >= 200 && status < 300, + Data: data, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONWithMeta(w http.ResponseWriter, status int, data any, meta *Meta) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: true, + Data: data, + Meta: meta, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONError(w http.ResponseWriter, err error) { + appErr := GetAppError(err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(appErr.StatusCode) + + response := Response{ + Success: false, + Error: &Error{ + Code: appErr.Code, + Message: appErr.Message, + }, + } + + if encErr := json.NewEncoder(w).Encode(response); encErr != nil { + slog.Error("failed to encode error response", "error", encErr) + } +} + +func Created(w http.ResponseWriter, data any) { + JSON(w, http.StatusCreated, data) +} + +func OK(w http.ResponseWriter, data any) { + JSON(w, http.StatusOK, data) +} + +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} + +func BadRequest(w http.ResponseWriter, message string) { + JSONError(w, ValidationError(message)) +} + +func NotFound(w http.ResponseWriter, resource string) { + JSONError(w, NotFoundError(resource)) +} + +func Unauthorized(w http.ResponseWriter, message string) { + JSONError(w, UnauthorizedError(message)) +} + +func Forbidden(w http.ResponseWriter, message string) { + JSONError(w, ForbiddenError(message)) +} + +func InternalServerError(w http.ResponseWriter, err error) { + slog.Error("internal server error", "error", err) + JSONError(w, InternalError(err)) +} + +func Paginated(w http.ResponseWriter, data any, page, pageSize, total int) { + totalPages := (total + pageSize - 1) / pageSize + JSONWithMeta(w, http.StatusOK, data, &Meta{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: totalPages, + }) +} diff --git a/stacks/go-react/go-backend/internal/core/security.go b/stacks/go-react/go-backend/internal/core/security.go new file mode 100644 index 0000000..05065e1 --- /dev/null +++ b/stacks/go-react/go-backend/internal/core/security.go @@ -0,0 +1,218 @@ +// AngelaMos | 2026 +// security.go + +package core + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 1 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 + saltLength = 16 +) + +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + hash := argon2.IDKey( + []byte(password), + salt, + argonTime, + argonMemory, + argonThreads, + argonKeyLen, + ) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + encoded := fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argonMemory, + argonTime, + argonThreads, + b64Salt, + b64Hash, + ) + + return encoded, nil +} + +func VerifyPassword(password, encodedHash string) (bool, error) { + params, salt, hash, err := decodeHash(encodedHash) + if err != nil { + return false, err + } + + otherHash := argon2.IDKey( + []byte(password), + salt, + params.time, + params.memory, + params.threads, + params.keyLen, + ) + + if subtle.ConstantTimeCompare(hash, otherHash) == 1 { + return true, nil + } + + return false, nil +} + +func VerifyPasswordWithRehash( + password, encodedHash string, +) (bool, string, error) { + valid, err := VerifyPassword(password, encodedHash) + if err != nil { + return false, "", err + } + + if !valid { + return false, "", nil + } + + if needsRehash(encodedHash) { + newHash, hashErr := HashPassword(password) + if hashErr != nil { + //nolint:nilerr // password verified successfully; rehash failure is non-critical + return true, "", nil + } + return true, newHash, nil + } + + return true, "", nil +} + +var dummyHash string + +func init() { + hash, err := HashPassword("dummy_password_for_timing_attack_prevention") + if err != nil { + panic(fmt.Sprintf("security: failed to generate dummy hash: %v", err)) + } + dummyHash = hash +} + +func VerifyPasswordTimingSafe( + password string, + encodedHash *string, +) (bool, string, error) { + hashToVerify := dummyHash + if encodedHash != nil && *encodedHash != "" { + hashToVerify = *encodedHash + } + + valid, newHash, err := VerifyPasswordWithRehash(password, hashToVerify) + + if encodedHash == nil || *encodedHash == "" { + return false, "", nil + } + + return valid, newHash, err +} + +type argonParams struct { + memory uint32 + time uint32 + threads uint8 + keyLen uint32 +} + +func decodeHash(encodedHash string) (*argonParams, []byte, []byte, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return nil, nil, nil, fmt.Errorf("invalid hash format") + } + + if parts[1] != "argon2id" { + return nil, nil, nil, fmt.Errorf("unsupported algorithm: %s", parts[1]) + } + + var version int + _, err := fmt.Sscanf(parts[2], "v=%d", &version) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid version: %w", err) + } + + if version != argon2.Version { + return nil, nil, nil, fmt.Errorf("incompatible version: %d", version) + } + + params := &argonParams{} + _, err = fmt.Sscanf( + parts[3], + "m=%d,t=%d,p=%d", + ¶ms.memory, + ¶ms.time, + ¶ms.threads, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid params: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode salt: %w", err) + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode hash: %w", err) + } + + //nolint:gosec // G115: hash length is always small (32 bytes for Argon2id) + params.keyLen = uint32(len(hash)) + + return params, salt, hash, nil +} + +func needsRehash(encodedHash string) bool { + params, _, _, err := decodeHash(encodedHash) + if err != nil { + return true + } + + return params.memory != argonMemory || + params.time != argonTime || + params.threads != argonThreads || + params.keyLen != argonKeyLen +} + +func GenerateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func GenerateRefreshToken() (string, error) { + return GenerateSecureToken(32) +} + +func HashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func CompareTokenHash(token, hash string) bool { + tokenHash := HashToken(token) + return subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hash)) == 1 +} diff --git a/stacks/go-react/go-backend/internal/core/telemetry.go b/stacks/go-react/go-backend/internal/core/telemetry.go new file mode 100644 index 0000000..950bba5 --- /dev/null +++ b/stacks/go-react/go-backend/internal/core/telemetry.go @@ -0,0 +1,142 @@ +// AngelaMos | 2026 +// telemetry.go + +package core + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +type Telemetry struct { + TracerProvider *sdktrace.TracerProvider + Tracer trace.Tracer +} + +func NewTelemetry( + ctx context.Context, + otelCfg config.OtelConfig, + appCfg config.AppConfig, +) (*Telemetry, error) { + if !otelCfg.Enabled || otelCfg.Endpoint == "" { + noopProvider := sdktrace.NewTracerProvider() + return &Telemetry{ + TracerProvider: noopProvider, + Tracer: noopProvider.Tracer(otelCfg.ServiceName), + }, nil + } + + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(otelCfg.Endpoint), + otlptracegrpc.WithTimeout(5 * time.Second), + } + + if otelCfg.Insecure { + opts = append( + opts, + otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()), + ) + } else { + opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, ""))) + } + + exporter, err := otlptracegrpc.New(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create otlp exporter: %w", err) + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(otelCfg.ServiceName), + semconv.ServiceVersion(appCfg.Version), + attribute.String("environment", appCfg.Environment), + ), + resource.WithHost(), + resource.WithProcess(), + ) + if err != nil { + return nil, fmt.Errorf("create resource: %w", err) + } + + sampleRate := otelCfg.SampleRate + if sampleRate <= 0 || sampleRate > 1 { + sampleRate = 0.1 + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter, + sdktrace.WithBatchTimeout(5*time.Second), + sdktrace.WithMaxExportBatchSize(512), + ), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.ParentBased( + sdktrace.TraceIDRatioBased(sampleRate), + )), + ) + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return &Telemetry{ + TracerProvider: tp, + Tracer: tp.Tracer(otelCfg.ServiceName), + }, nil +} + +func (t *Telemetry) Shutdown(ctx context.Context) error { + if t.TracerProvider == nil { + return nil + } + + shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := t.TracerProvider.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown tracer provider: %w", err) + } + + return nil +} + +func SpanFromContext(ctx context.Context) trace.Span { + return trace.SpanFromContext(ctx) +} + +func TraceIDFromContext(ctx context.Context) string { + span := trace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + return span.SpanContext().TraceID().String() + } + return "" +} + +func AddSpanEvent( + ctx context.Context, + name string, + attrs ...attribute.KeyValue, +) { + span := trace.SpanFromContext(ctx) + span.AddEvent(name, trace.WithAttributes(attrs...)) +} + +func SetSpanError(ctx context.Context, err error) { + span := trace.SpanFromContext(ctx) + span.RecordError(err) +} diff --git a/stacks/go-react/go-backend/internal/core/validation.go b/stacks/go-react/go-backend/internal/core/validation.go new file mode 100644 index 0000000..f77cbd9 --- /dev/null +++ b/stacks/go-react/go-backend/internal/core/validation.go @@ -0,0 +1,42 @@ +// AngelaMos | 2026 +// validation.go + +package core + +import ( + "errors" + "strings" + + "github.com/go-playground/validator/v10" +) + +func FormatValidationError(err error) string { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + messages := make([]string, 0, len(ve)) + for _, fe := range ve { + messages = append(messages, FormatFieldError(fe)) + } + return strings.Join(messages, "; ") + } + return "validation failed" +} + +func FormatFieldError(fe validator.FieldError) string { + field := strings.ToLower(fe.Field()) + + switch fe.Tag() { + case "required": + return field + " is required" + case "email": + return field + " must be a valid email" + case "min": + return field + " must be at least " + fe.Param() + " characters" + case "max": + return field + " must be at most " + fe.Param() + " characters" + case "oneof": + return field + " must be one of: " + fe.Param() + default: + return field + " is invalid" + } +} diff --git a/stacks/go-react/go-backend/internal/health/handler.go b/stacks/go-react/go-backend/internal/health/handler.go new file mode 100644 index 0000000..e7003c6 --- /dev/null +++ b/stacks/go-react/go-backend/internal/health/handler.go @@ -0,0 +1,195 @@ +// AngelaMos | 2026 +// handler.go + +package health + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" +) + +type Checker interface { + Ping(ctx context.Context) error +} + +type Handler struct { + db Checker + redis Checker + ready atomic.Bool + shutdown atomic.Bool +} + +func NewHandler(db, redis Checker) *Handler { + h := &Handler{ + db: db, + redis: redis, + } + h.ready.Store(true) + return h +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/healthz", h.Liveness) + r.Get("/livez", h.Liveness) + r.Get("/readyz", h.Readiness) +} + +func (h *Handler) Liveness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + h.writeStatus(w, http.StatusOK, StatusResponse{ + Status: "ok", + }) +} + +func (h *Handler) Readiness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + if !h.ready.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "not_ready", + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + checks := h.runHealthChecks(ctx) + + allHealthy := true + for _, check := range checks { + if !check.Healthy { + allHealthy = false + break + } + } + + status := "ok" + statusCode := http.StatusOK + if !allHealthy { + status = "degraded" + statusCode = http.StatusServiceUnavailable + } + + h.writeStatus(w, statusCode, ReadinessResponse{ + Status: status, + Checks: checks, + }) +} + +func (h *Handler) runHealthChecks(ctx context.Context) []HealthCheck { + var wg sync.WaitGroup + checks := make([]HealthCheck, 2) + + wg.Add(2) + + go func() { + defer wg.Done() + checks[0] = h.checkDatabase(ctx) + }() + + go func() { + defer wg.Done() + checks[1] = h.checkRedis(ctx) + }() + + wg.Wait() + return checks +} + +func (h *Handler) checkDatabase(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "database", + Healthy: true, + } + + if h.db == nil { + check.Healthy = false + check.Message = "database checker not configured" + return check + } + + start := time.Now() + err := h.db.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) checkRedis(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "redis", + Healthy: true, + } + + if h.redis == nil { + check.Healthy = false + check.Message = "redis checker not configured" + return check + } + + start := time.Now() + err := h.redis.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) SetReady(ready bool) { + h.ready.Store(ready) +} + +func (h *Handler) SetShutdown(shutdown bool) { + h.shutdown.Store(shutdown) +} + +func (h *Handler) writeStatus(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.WriteHeader(status) + //nolint:errcheck // best-effort response + _ = json.NewEncoder(w).Encode(data) +} + +type StatusResponse struct { + Status string `json:"status"` +} + +type ReadinessResponse struct { + Status string `json:"status"` + Checks []HealthCheck `json:"checks"` +} + +type HealthCheck struct { + Name string `json:"name"` + Healthy bool `json:"healthy"` + Latency string `json:"latency,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/stacks/go-react/go-backend/internal/middleware/auth.go b/stacks/go-react/go-backend/internal/middleware/auth.go new file mode 100644 index 0000000..0f9fb5f --- /dev/null +++ b/stacks/go-react/go-backend/internal/middleware/auth.go @@ -0,0 +1,189 @@ +// AngelaMos | 2026 +// auth.go + +package middleware + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +const ( + UserIDKey contextKey = "user_id" + UserRoleKey contextKey = "user_role" + UserTierKey contextKey = "user_tier" + ClaimsKey contextKey = "jwt_claims" +) + +type TokenVerifier interface { + VerifyAccessToken( + ctx context.Context, + token string, + ) (*AccessTokenClaims, error) +} + +type AccessTokenClaims struct { + UserID string + Role string + Tier string + TokenVersion int +} + +func Authenticator(verifier TokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := ExtractToken(r) + + if token == "" { + core.JSONError( + w, + core.UnauthorizedError("missing authorization token"), + ) + return + } + + claims, err := verifier.VerifyAccessToken(r.Context(), token) + if err != nil { + handleAuthError(w, err) + return + } + + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, UserTierKey, claims.Tier) + ctx = context.WithValue(ctx, ClaimsKey, claims) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func OptionalAuth(verifier TokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := ExtractToken(r) + + if token != "" { + claims, err := verifier.VerifyAccessToken(r.Context(), token) + if err == nil { + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, UserTierKey, claims.Tier) + ctx = context.WithValue(ctx, ClaimsKey, claims) + r = r.WithContext(ctx) + } + } + + next.ServeHTTP(w, r) + }) + } +} + +func RequireRole(roles ...string) func(http.Handler) http.Handler { + roleSet := make(map[string]struct{}, len(roles)) + for _, role := range roles { + roleSet[role] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := GetUserRole(r.Context()) + + if userRole == "" { + core.JSONError( + w, + core.UnauthorizedError("authentication required"), + ) + return + } + + if _, ok := roleSet[userRole]; !ok { + core.JSONError( + w, + core.ForbiddenError("insufficient permissions"), + ) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func RequireAdmin(next http.Handler) http.Handler { + return RequireRole("admin")(next) +} + +func ExtractToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "" + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "" + } + + return strings.TrimSpace(parts[1]) +} + +func handleAuthError(w http.ResponseWriter, err error) { + if core.IsAppError(err) { + core.JSONError(w, err) + return + } + + switch { + case errors.Is(err, core.ErrTokenExpired): + core.JSONError(w, core.TokenExpiredError()) + case errors.Is(err, core.ErrTokenRevoked): + core.JSONError(w, core.TokenRevokedError()) + case errors.Is(err, core.ErrTokenInvalid): + core.JSONError(w, core.TokenInvalidError()) + default: + core.JSONError(w, core.TokenInvalidError()) + } +} + +func GetUserID(ctx context.Context) string { + if id, ok := ctx.Value(UserIDKey).(string); ok { + return id + } + return "" +} + +func GetUserRole(ctx context.Context) string { + if role, ok := ctx.Value(UserRoleKey).(string); ok { + return role + } + return "" +} + +func GetUserTier(ctx context.Context) string { + if tier, ok := ctx.Value(UserTierKey).(string); ok { + return tier + } + return "" +} + +func GetClaims(ctx context.Context) *AccessTokenClaims { + if claims, ok := ctx.Value(ClaimsKey).(*AccessTokenClaims); ok { + return claims + } + return nil +} + +func IsAuthenticated(ctx context.Context) bool { + return GetUserID(ctx) != "" +} + +func IsAdmin(ctx context.Context) bool { + return GetUserRole(ctx) == "admin" +} diff --git a/stacks/go-react/go-backend/internal/middleware/headers.go b/stacks/go-react/go-backend/internal/middleware/headers.go new file mode 100644 index 0000000..332d48f --- /dev/null +++ b/stacks/go-react/go-backend/internal/middleware/headers.go @@ -0,0 +1,103 @@ +// AngelaMos | 2026 +// headers.go + +package middleware + +import ( + "net/http" + "strconv" + "strings" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +func SecurityHeaders(isProduction bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("X-XSS-Protection", "1; mode=block") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()", + ) + + if isProduction { + h.Set( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload", + ) + } + + h.Set("Content-Security-Policy", buildCSP(isProduction)) + + next.ServeHTTP(w, r) + }) + } +} + +func buildCSP(isProduction bool) string { + directives := []string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + } + + if !isProduction { + directives[1] = "script-src 'self' 'unsafe-inline' 'unsafe-eval'" + } + + return strings.Join(directives, "; ") +} + +func CORS(cfg config.CORSConfig) func(http.Handler) http.Handler { + allowedOrigins := make(map[string]struct{}, len(cfg.AllowedOrigins)) + for _, origin := range cfg.AllowedOrigins { + allowedOrigins[origin] = struct{}{} + } + + methodsStr := strings.Join(cfg.AllowedMethods, ", ") + headersStr := strings.Join(cfg.AllowedHeaders, ", ") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin != "" { + if _, ok := allowedOrigins[origin]; ok { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + + if cfg.AllowCredentials { + w.Header(). + Set("Access-Control-Allow-Credentials", "true") + } + } + } + + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", methodsStr) + w.Header().Set("Access-Control-Allow-Headers", headersStr) + + if cfg.MaxAge > 0 { + w.Header(). + Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge)) + } + + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/stacks/go-react/go-backend/internal/middleware/logging.go b/stacks/go-react/go-backend/internal/middleware/logging.go new file mode 100644 index 0000000..602ed4c --- /dev/null +++ b/stacks/go-react/go-backend/internal/middleware/logging.go @@ -0,0 +1,99 @@ +// AngelaMos | 2026 +// logging.go + +package middleware + +import ( + "context" + "log/slog" + "net/http" + "time" + + "go.opentelemetry.io/otel/trace" +) + +type loggerKey struct{} + +func Logger(baseLogger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + requestID := GetRequestID(r.Context()) + + reqLogger := baseLogger.With( + slog.String("request_id", requestID), + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + ) + + if span := trace.SpanFromContext(r.Context()); span.SpanContext(). + IsValid() { + reqLogger = reqLogger.With( + slog.String( + "trace_id", + span.SpanContext().TraceID().String(), + ), + slog.String( + "span_id", + span.SpanContext().SpanID().String(), + ), + ) + } + + ctx := context.WithValue(r.Context(), loggerKey{}, reqLogger) + + ww := &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + + next.ServeHTTP(ww, r.WithContext(ctx)) + + latency := time.Since(start) + + logLevel := slog.LevelInfo + if ww.status >= 500 { + logLevel = slog.LevelError + } else if ww.status >= 400 { + logLevel = slog.LevelWarn + } + + reqLogger.Log(r.Context(), logLevel, "request completed", + slog.Int("status", ww.status), + slog.Int("bytes", ww.bytes), + slog.Duration("latency", latency), + slog.String("user_agent", r.UserAgent()), + ) + }) + } +} + +func GetLogger(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +type responseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + n, err := rw.ResponseWriter.Write(b) + rw.bytes += n + return n, err +} + +func (rw *responseWriter) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} diff --git a/stacks/go-react/go-backend/internal/middleware/ratelimit.go b/stacks/go-react/go-backend/internal/middleware/ratelimit.go new file mode 100644 index 0000000..c85519a --- /dev/null +++ b/stacks/go-react/go-backend/internal/middleware/ratelimit.go @@ -0,0 +1,375 @@ +// AngelaMos | 2026 +// ratelimit.go + +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + redis_rate "github.com/go-redis/redis_rate/v10" + "github.com/redis/go-redis/v9" + "golang.org/x/time/rate" +) + +type RateLimitConfig struct { + Limit redis_rate.Limit + KeyFunc func(*http.Request) string + FailOpen bool + BypassFunc func(*http.Request) bool + OnLimited func(http.ResponseWriter, *http.Request, *redis_rate.Result) +} + +type RateLimiter struct { + limiter *redis_rate.Limiter + fallback *localLimiter + config RateLimitConfig +} + +func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiter { + if cfg.KeyFunc == nil { + cfg.KeyFunc = KeyByIP + } + + return &RateLimiter{ + limiter: redis_rate.NewLimiter(rdb), + fallback: newLocalLimiter(), + config: cfg, + } +} + +func (rl *RateLimiter) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if rl.config.BypassFunc != nil && rl.config.BypassFunc(r) { + next.ServeHTTP(w, r) + return + } + + key := rl.config.KeyFunc(r) + res, err := rl.allow(r.Context(), key) + if err != nil { + if rl.config.FailOpen { + slog.Warn("rate limiter error, failing open", + "error", err, + "key", key, + ) + next.ServeHTTP(w, r) + return + } + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + + setRateLimitHeaders(w, res, rl.config.Limit) + + if res.Allowed == 0 { + if rl.config.OnLimited != nil { + rl.config.OnLimited(w, r, res) + return + } + writeRateLimitExceeded(w, res) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) allow( + ctx context.Context, + key string, +) (*redis_rate.Result, error) { + res, err := rl.limiter.Allow(ctx, key, rl.config.Limit) + if err != nil { + return rl.fallback.allow(key, rl.config.Limit) + } + return res, nil +} + +func KeyByIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + ip := strings.TrimSpace(ips[len(ips)-1]) + return "ratelimit:ip:" + ip + } + + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return "ratelimit:ip:" + xri + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + + return "ratelimit:ip:" + ip +} + +func KeyByUser(r *http.Request) string { + if userID := GetUserID(r.Context()); userID != "" { + return "ratelimit:user:" + userID + } + return KeyByIP(r) +} + +func KeyByUserAndEndpoint(r *http.Request) string { + userKey := KeyByUser(r) + endpoint := normalizeEndpoint(r.URL.Path) + return fmt.Sprintf("%s:endpoint:%s", userKey, endpoint) +} + +func normalizeEndpoint(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + normalized := make([]string, 0, len(parts)) + + for _, part := range parts { + if isUUID(part) || isNumeric(part) { + normalized = append(normalized, "{id}") + } else { + normalized = append(normalized, part) + } + } + + return "/" + strings.Join(normalized, "/") +} + +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + return s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-' +} + +func isNumeric(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return len(s) > 0 +} + +func setRateLimitHeaders( + w http.ResponseWriter, + res *redis_rate.Result, + limit redis_rate.Limit, +) { + h := w.Header() + + h.Set("X-RateLimit-Limit", strconv.Itoa(limit.Rate)) + h.Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining)) + h.Set("X-RateLimit-Reset", strconv.FormatInt( + time.Now().Add(res.ResetAfter).Unix(), 10)) + + windowSecs := int(limit.Period.Seconds()) + h.Set("RateLimit-Policy", fmt.Sprintf(`%d;w=%d`, limit.Rate, windowSecs)) + h.Set( + "RateLimit", + fmt.Sprintf(`%d;t=%d`, res.Remaining, int(res.ResetAfter.Seconds())), + ) +} + +func writeRateLimitExceeded(w http.ResponseWriter, res *redis_rate.Result) { + retryAfter := int(res.RetryAfter.Seconds()) + if retryAfter < 1 { + retryAfter = 1 + } + + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + + response := map[string]any{ + "success": false, + "error": map[string]any{ + "code": "RATE_LIMITED", + "message": fmt.Sprintf( + "Rate limit exceeded. Retry after %d seconds.", + retryAfter, + ), + }, + } + + //nolint:errcheck // best-effort response write + _ = json.NewEncoder(w).Encode(response) +} + +type limiterEntry struct { + limiter *rate.Limiter + lastAccess int64 +} + +type localLimiter struct { + limiters sync.Map +} + +const ( + cleanupInterval = 5 * time.Minute + entryTTL = 10 * time.Minute +) + +func newLocalLimiter() *localLimiter { + l := &localLimiter{} + go l.cleanup() + return l +} + +func (l *localLimiter) cleanup() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for range ticker.C { + cutoff := time.Now().Add(-entryTTL).Unix() + l.limiters.Range(func(key, value any) bool { + entry, ok := value.(*limiterEntry) + if ok && entry.lastAccess < cutoff { + l.limiters.Delete(key) + } + return true + }) + } +} + +func (l *localLimiter) allow( + key string, + limit redis_rate.Limit, +) (*redis_rate.Result, error) { + ratePerSec := float64(limit.Rate) / limit.Period.Seconds() + now := time.Now().Unix() + + entryI, loaded := l.limiters.Load(key) + if !loaded { + newEntry := &limiterEntry{ + limiter: rate.NewLimiter( + rate.Limit(ratePerSec), + limit.Burst, + ), + lastAccess: now, + } + entryI, _ = l.limiters.LoadOrStore(key, newEntry) + } + + entry, ok := entryI.(*limiterEntry) + if !ok { + return nil, fmt.Errorf("invalid limiter entry type") + } + entry.lastAccess = now + + allowed := entry.limiter.Allow() + + remaining := int(entry.limiter.Tokens()) + if remaining < 0 { + remaining = 0 + } + + var retryAfter time.Duration + if !allowed { + retryAfter = time.Duration(float64(time.Second) / ratePerSec) + } else { + retryAfter = -1 + } + + allowedInt := 0 + if allowed { + allowedInt = 1 + } + + return &redis_rate.Result{ + Limit: limit, + Allowed: allowedInt, + Remaining: remaining, + RetryAfter: retryAfter, + ResetAfter: time.Duration(float64(time.Second) / ratePerSec), + }, nil +} + +type TierConfig struct { + RequestsPerMinute int + BurstSize int +} + +var DefaultTiers = map[string]TierConfig{ + "free": {RequestsPerMinute: 60, BurstSize: 10}, + "pro": {RequestsPerMinute: 600, BurstSize: 100}, + "enterprise": {RequestsPerMinute: 6000, BurstSize: 1000}, +} + +func TieredRateLimiter( + rdb *redis.Client, + tiers map[string]TierConfig, +) func(http.Handler) http.Handler { + limiter := redis_rate.NewLimiter(rdb) + fallback := newLocalLimiter() + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := GetUserID(r.Context()) + tier := GetUserTier(r.Context()) + + if tier == "" { + tier = "free" + } + + config, ok := tiers[tier] + if !ok { + config = tiers["free"] + } + + limit := redis_rate.Limit{ + Rate: config.RequestsPerMinute, + Burst: config.BurstSize, + Period: time.Minute, + } + + key := fmt.Sprintf("ratelimit:user:%s", userID) + + res, err := limiter.Allow(r.Context(), key, limit) + if err != nil { + //nolint:errcheck // fallback never fails + res, _ = fallback.allow(key, limit) + } + + w.Header().Set("X-RateLimit-Tier", tier) + setRateLimitHeaders(w, res, limit) + + if res.Allowed == 0 { + writeRateLimitExceeded(w, res) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func PerMinute(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Minute, + } +} + +func PerSecond(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Second, + } +} + +func PerHour(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Hour, + } +} diff --git a/stacks/go-react/go-backend/internal/middleware/request_id.go b/stacks/go-react/go-backend/internal/middleware/request_id.go new file mode 100644 index 0000000..77d9816 --- /dev/null +++ b/stacks/go-react/go-backend/internal/middleware/request_id.go @@ -0,0 +1,40 @@ +// AngelaMos | 2026 +// request_id.go + +package middleware + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +type contextKey string + +const RequestIDKey contextKey = "request_id" + +const RequestIDHeader = "X-Request-ID" + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := r.Header.Get(RequestIDHeader) + + if requestID == "" { + requestID = uuid.New().String() + } + + ctx := context.WithValue(r.Context(), RequestIDKey, requestID) + + w.Header().Set(RequestIDHeader, requestID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetRequestID(ctx context.Context) string { + if id, ok := ctx.Value(RequestIDKey).(string); ok { + return id + } + return "" +} diff --git a/stacks/go-react/go-backend/internal/server/server.go b/stacks/go-react/go-backend/internal/server/server.go new file mode 100644 index 0000000..58d5cba --- /dev/null +++ b/stacks/go-react/go-backend/internal/server/server.go @@ -0,0 +1,108 @@ +// AngelaMos | 2026 +// server.go + +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + + "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/templates/go-backend/internal/health" +) + +type Server struct { + httpServer *http.Server + router *chi.Mux + config config.ServerConfig + healthHandler *health.Handler + logger *slog.Logger +} + +type Config struct { + ServerConfig config.ServerConfig + HealthHandler *health.Handler + Logger *slog.Logger +} + +func New(cfg Config) *Server { + router := chi.NewRouter() + + router.Use(chimw.CleanPath) + router.Use(chimw.StripSlashes) + + return &Server{ + httpServer: &http.Server{ + Addr: cfg.ServerConfig.Address(), + Handler: router, + ReadTimeout: cfg.ServerConfig.ReadTimeout, + WriteTimeout: cfg.ServerConfig.WriteTimeout, + IdleTimeout: cfg.ServerConfig.IdleTimeout, + }, + router: router, + config: cfg.ServerConfig, + healthHandler: cfg.HealthHandler, + logger: cfg.Logger, + } +} + +func (s *Server) Router() *chi.Mux { + return s.router +} + +func (s *Server) Start() error { + s.logger.Info("starting HTTP server", + "addr", s.config.Address(), + "read_timeout", s.config.ReadTimeout, + "write_timeout", s.config.WriteTimeout, + "idle_timeout", s.config.IdleTimeout, + ) + + if err := s.httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server error: %w", err) + } + + return nil +} + +func (s *Server) Shutdown(ctx context.Context, drainDelay time.Duration) error { + s.logger.Info("initiating graceful shutdown") + + s.logger.Info("marking server as not ready") + if s.healthHandler != nil { + s.healthHandler.SetReady(false) + s.healthHandler.SetShutdown(true) + } + + s.logger.Info("waiting for load balancer to drain", + "delay", drainDelay, + ) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(drainDelay): + } + + s.logger.Info("stopping HTTP server") + shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout) + defer cancel() + + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("http server shutdown: %w", err) + } + + s.logger.Info("HTTP server stopped gracefully") + return nil +} + +func (s *Server) Address() string { + return s.httpServer.Addr +} diff --git a/stacks/go-react/go-backend/internal/user/dto.go b/stacks/go-react/go-backend/internal/user/dto.go new file mode 100644 index 0000000..ba2b068 --- /dev/null +++ b/stacks/go-react/go-backend/internal/user/dto.go @@ -0,0 +1,84 @@ +// AngelaMos | 2026 +// dto.go + +package user + +import ( + "time" +) + +type CreateUserRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` +} + +type UpdateUserRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` +} + +type UpdateUserRoleRequest struct { + Role string `json:"role" validate:"required,oneof=user admin"` +} + +type UpdateUserTierRequest struct { + Tier string `json:"tier" validate:"required,oneof=free pro enterprise"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Tier string `json:"tier"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserListResponse struct { + Users []UserResponse `json:"users"` +} + +type ListUsersParams struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Search string `json:"search"` + Role string `json:"role"` + Tier string `json:"tier"` +} + +func (p *ListUsersParams) Normalize() { + if p.Page < 1 { + p.Page = 1 + } + if p.PageSize < 1 { + p.PageSize = 20 + } + if p.PageSize > 100 { + p.PageSize = 100 + } +} + +func (p *ListUsersParams) Offset() int { + return (p.Page - 1) * p.PageSize +} + +func ToUserResponse(u *User) UserResponse { + return UserResponse{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + Role: u.Role, + Tier: u.Tier, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +func ToUserResponseList(users []User) []UserResponse { + responses := make([]UserResponse, 0, len(users)) + for _, u := range users { + responses = append(responses, ToUserResponse(&u)) + } + return responses +} diff --git a/stacks/go-react/go-backend/internal/user/entity.go b/stacks/go-react/go-backend/internal/user/entity.go new file mode 100644 index 0000000..f830b05 --- /dev/null +++ b/stacks/go-react/go-backend/internal/user/entity.go @@ -0,0 +1,40 @@ +// AngelaMos | 2026 +// entity.go + +package user + +import ( + "time" +) + +type User struct { + ID string `db:"id"` + Email string `db:"email"` + PasswordHash string `db:"password_hash"` + Name string `db:"name"` + Role string `db:"role"` + Tier string `db:"tier"` + TokenVersion int `db:"token_version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` +} + +func (u *User) IsDeleted() bool { + return u.DeletedAt != nil +} + +func (u *User) IsAdmin() bool { + return u.Role == RoleAdmin +} + +const ( + RoleUser = "user" + RoleAdmin = "admin" +) + +const ( + TierFree = "free" + TierPro = "pro" + TierEnterprise = "enterprise" +) diff --git a/stacks/go-react/go-backend/internal/user/handler.go b/stacks/go-react/go-backend/internal/user/handler.go new file mode 100644 index 0000000..8a21889 --- /dev/null +++ b/stacks/go-react/go-backend/internal/user/handler.go @@ -0,0 +1,288 @@ +// AngelaMos | 2026 +// handler.go + +package user + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" +) + +type Handler struct { + service *Service + validator *validator.Validate +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/users", func(r chi.Router) { + r.Use(authenticator) + + r.Get("/me", h.GetMe) + r.Put("/me", h.UpdateMe) + r.Delete("/me", h.DeleteMe) + }) +} + +func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + user, err := h.service.GetMe(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateMe(r.Context(), userID, req) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +func (h *Handler) DeleteMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + if err := h.service.DeleteMe(r.Context(), userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +// RegisterAdminRoutes registers admin-only user management endpoints. +func (h *Handler) RegisterAdminRoutes( + r chi.Router, + authenticator, adminOnly func(http.Handler) http.Handler, +) { + r.Route("/admin/users", func(r chi.Router) { + r.Use(authenticator) + r.Use(adminOnly) + + r.Get("/", h.ListUsers) + r.Get("/{userID}", h.GetUser) + r.Put("/{userID}", h.UpdateUser) + r.Put("/{userID}/role", h.UpdateUserRole) + r.Put("/{userID}/tier", h.UpdateUserTier) + r.Delete("/{userID}", h.DeleteUser) + }) +} + +// ListUsers returns a paginated list of users with optional filtering. +func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { + params := ListUsersParams{ + Page: parseIntQuery(r, "page", 1), + PageSize: parseIntQuery(r, "page_size", 20), + Search: r.URL.Query().Get("search"), + Role: r.URL.Query().Get("role"), + Tier: r.URL.Query().Get("tier"), + } + + users, total, err := h.service.ListUsers(r.Context(), params) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.Paginated( + w, + ToUserResponseList(users), + params.Page, + params.PageSize, + total, + ) +} + +// GetUser returns a specific user by ID (admin only). +func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + user, err := h.service.GetUser(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// UpdateUser updates a specific user's profile (admin only). +func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + var req UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateUser(r.Context(), userID, req) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// UpdateUserRole changes a user's role (admin only). +func (h *Handler) UpdateUserRole(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + var req UpdateUserRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateUserRole(r.Context(), userID, req.Role) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// UpdateUserTier changes a user's subscription tier (admin only). +func (h *Handler) UpdateUserTier(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + var req UpdateUserTierRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateUserTier(r.Context(), userID, req.Tier) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// DeleteUser soft deletes a user account (admin only). +func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { + requesterID := middleware.GetUserID(r.Context()) + targetID := chi.URLParam(r, "userID") + + if err := h.service.CanDeleteUser(r.Context(), requesterID, targetID); err != nil { + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "insufficient permissions") + return + } + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + if err := h.service.DeleteUser(r.Context(), targetID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func parseIntQuery(r *http.Request, key string, defaultVal int) int { + val := r.URL.Query().Get(key) + if val == "" { + return defaultVal + } + + parsed, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + + return parsed +} diff --git a/stacks/go-react/go-backend/internal/user/repository.go b/stacks/go-react/go-backend/internal/user/repository.go new file mode 100644 index 0000000..a255b08 --- /dev/null +++ b/stacks/go-react/go-backend/internal/user/repository.go @@ -0,0 +1,289 @@ +// AngelaMos | 2026 +// repository.go + +package user + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type Repository interface { + Create(ctx context.Context, user *User) error + GetByID(ctx context.Context, id string) (*User, error) + GetByEmail(ctx context.Context, email string) (*User, error) + Update(ctx context.Context, user *User) error + UpdatePassword(ctx context.Context, id, passwordHash string) error + IncrementTokenVersion(ctx context.Context, id string) error + SoftDelete(ctx context.Context, id string) error + List(ctx context.Context, params ListUsersParams) ([]User, int, error) + ExistsByEmail(ctx context.Context, email string) (bool, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) Create(ctx context.Context, user *User) error { + query := ` + INSERT INTO users (id, email, password_hash, name, role, tier) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at, token_version` + + err := r.db.GetContext(ctx, user, query, + user.ID, + user.Email, + user.PasswordHash, + user.Name, + user.Role, + user.Tier, + ) + if err != nil { + if isDuplicateKeyError(err) { + return fmt.Errorf("create user: %w", core.ErrDuplicateKey) + } + return fmt.Errorf("create user: %w", err) + } + + return nil +} + +func (r *repository) GetByID(ctx context.Context, id string) (*User, error) { + query := ` + SELECT id, email, password_hash, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE id = $1 AND deleted_at IS NULL` + + var user User + err := r.db.GetContext(ctx, &user, query, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get user: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + return &user, nil +} + +func (r *repository) GetByEmail( + ctx context.Context, + email string, +) (*User, error) { + query := ` + SELECT id, email, password_hash, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE email = $1 AND deleted_at IS NULL` + + var user User + err := r.db.GetContext(ctx, &user, query, email) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get user by email: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get user by email: %w", err) + } + + return &user, nil +} + +func (r *repository) Update(ctx context.Context, user *User) error { + query := ` + UPDATE users + SET name = $2, role = $3, tier = $4, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + RETURNING updated_at` + + err := r.db.GetContext(ctx, &user.UpdatedAt, query, + user.ID, + user.Name, + user.Role, + user.Tier, + ) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("update user: %w", core.ErrNotFound) + } + if err != nil { + return fmt.Errorf("update user: %w", err) + } + + return nil +} + +func (r *repository) UpdatePassword( + ctx context.Context, + id, passwordHash string, +) error { + query := ` + UPDATE users + SET password_hash = $2, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id, passwordHash) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update password: %w", err) + } + + if rows == 0 { + return fmt.Errorf("update password: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) IncrementTokenVersion( + ctx context.Context, + id string, +) error { + query := ` + UPDATE users + SET token_version = token_version + 1, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + if rows == 0 { + return fmt.Errorf("increment token version: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) SoftDelete(ctx context.Context, id string) error { + query := ` + UPDATE users + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + + if rows == 0 { + return fmt.Errorf("delete user: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) List( + ctx context.Context, + params ListUsersParams, +) ([]User, int, error) { + params.Normalize() + + var conditions []string + var args []any + argIdx := 1 + + conditions = append(conditions, "deleted_at IS NULL") + + if params.Search != "" { + conditions = append(conditions, fmt.Sprintf( + "(email ILIKE $%d OR name ILIKE $%d)", argIdx, argIdx)) + args = append(args, "%"+escapeLike(params.Search)+"%") + argIdx++ + } + + if params.Role != "" { + conditions = append(conditions, fmt.Sprintf("role = $%d", argIdx)) + args = append(args, params.Role) + argIdx++ + } + + if params.Tier != "" { + conditions = append(conditions, fmt.Sprintf("tier = $%d", argIdx)) + args = append(args, params.Tier) + argIdx++ + } + + whereClause := strings.Join(conditions, " AND ") + + countQuery := fmt.Sprintf( + "SELECT COUNT(*) FROM users WHERE %s", + whereClause, + ) + var total int + if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { + return nil, 0, fmt.Errorf("count users: %w", err) + } + + query := fmt.Sprintf(` + SELECT id, email, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE %s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d`, + whereClause, argIdx, argIdx+1) + + args = append(args, params.PageSize, params.Offset()) + + var users []User + if err := r.db.SelectContext(ctx, &users, query, args...); err != nil { + return nil, 0, fmt.Errorf("list users: %w", err) + } + + return users, total, nil +} + +func (r *repository) ExistsByEmail( + ctx context.Context, + email string, +) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND deleted_at IS NULL)` + + var exists bool + if err := r.db.GetContext(ctx, &exists, query, email); err != nil { + return false, fmt.Errorf("check email exists: %w", err) + } + + return exists, nil +} + +func isDuplicateKeyError(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} + +func escapeLike(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "%", "\\%") + s = strings.ReplaceAll(s, "_", "\\_") + return s +} diff --git a/stacks/go-react/go-backend/internal/user/service.go b/stacks/go-react/go-backend/internal/user/service.go new file mode 100644 index 0000000..699d24d --- /dev/null +++ b/stacks/go-react/go-backend/internal/user/service.go @@ -0,0 +1,256 @@ +// AngelaMos | 2026 +// service.go + +package user + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/carterperez-dev/templates/go-backend/internal/auth" + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetByID( + ctx context.Context, + id string, +) (*auth.UserInfo, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) GetByEmail( + ctx context.Context, + email string, +) (*auth.UserInfo, error) { + user, err := s.repo.GetByEmail(ctx, strings.ToLower(email)) + if err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) Create( + ctx context.Context, + email, passwordHash, name string, +) (*auth.UserInfo, error) { + user := &User{ + ID: uuid.New().String(), + Email: strings.ToLower(email), + PasswordHash: passwordHash, + Name: name, + Role: RoleUser, + Tier: TierFree, + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) IncrementTokenVersion( + ctx context.Context, + userID string, +) error { + return s.repo.IncrementTokenVersion(ctx, userID) +} + +func (s *Service) UpdatePassword( + ctx context.Context, + userID, passwordHash string, +) error { + return s.repo.UpdatePassword(ctx, userID, passwordHash) +} + +func (s *Service) GetUser(ctx context.Context, id string) (*User, error) { + return s.repo.GetByID(ctx, id) +} + +func (s *Service) UpdateUser( + ctx context.Context, + id string, + req UpdateUserRequest, +) (*User, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + user.Name = *req.Name + } + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) UpdateUserRole( + ctx context.Context, + id, role string, +) (*User, error) { + if role != RoleUser && role != RoleAdmin { + return nil, fmt.Errorf( + "update role: invalid role %q: %w", + role, + core.ErrInvalidInput, + ) + } + + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + user.Role = role + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) UpdateUserTier( + ctx context.Context, + id, tier string, +) (*User, error) { + if tier != TierFree && tier != TierPro && tier != TierEnterprise { + return nil, fmt.Errorf( + "update tier: invalid tier %q: %w", + tier, + core.ErrInvalidInput, + ) + } + + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + user.Tier = tier + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) DeleteUser(ctx context.Context, id string) error { + return s.repo.SoftDelete(ctx, id) +} + +func (s *Service) ListUsers( + ctx context.Context, + params ListUsersParams, +) ([]User, int, error) { + return s.repo.List(ctx, params) +} + +func (s *Service) GetMe(ctx context.Context, userID string) (*User, error) { + if userID == "" { + return nil, fmt.Errorf("get me: %w", core.ErrUnauthorized) + } + + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) UpdateMe( + ctx context.Context, + userID string, + req UpdateUserRequest, +) (*User, error) { + if userID == "" { + return nil, fmt.Errorf("update me: %w", core.ErrUnauthorized) + } + + return s.UpdateUser(ctx, userID, req) +} + +func (s *Service) DeleteMe(ctx context.Context, userID string) error { + if userID == "" { + return fmt.Errorf("delete me: %w", core.ErrUnauthorized) + } + + return s.repo.SoftDelete(ctx, userID) +} + +func (s *Service) EmailExists( + ctx context.Context, + email string, +) (bool, error) { + exists, err := s.repo.ExistsByEmail(ctx, email) + if err != nil { + return false, err + } + return exists, nil +} + +func (s *Service) CanDeleteUser( + ctx context.Context, + requesterID, targetID string, +) error { + if requesterID == targetID { + return nil + } + + requester, err := s.repo.GetByID(ctx, requesterID) + if err != nil { + return err + } + + if !requester.IsAdmin() { + return fmt.Errorf("delete user: %w", core.ErrForbidden) + } + + target, err := s.repo.GetByID(ctx, targetID) + if err != nil { + return err + } + + if target.IsAdmin() { + return fmt.Errorf("cannot delete admin users: %w", core.ErrForbidden) + } + + return nil +} + +func toUserInfo(u *User) *auth.UserInfo { + return &auth.UserInfo{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + PasswordHash: u.PasswordHash, + Role: u.Role, + Tier: u.Tier, + TokenVersion: u.TokenVersion, + } +} + +var _ auth.UserProvider = (*Service)(nil)