feat: implement project foundation & infrastructure (epic #5)

- #35: Chi router with structured logging, panic recovery, request ID, graceful shutdown
- #36: golang-migrate framework with initial extensions migration
- #37: Expanded config package with server, database, CORS, rate limit settings
- #38: GitHub Actions CI pipeline for Go and React
- #39: Multi-stage Dockerfile for Go API (alpine, non-root, <30MB)
- #40: Multi-stage Dockerfile for React frontend with Nginx SPA routing
- #41: Production Docker Compose with all services and health checks
- #44: Health check endpoints (/healthz liveness, /readyz readiness)
- #45: CORS, security headers, and rate limiting middleware

Closes #35, closes #36, closes #37, closes #38, closes #39, closes #40, closes #41, closes #44, closes #45
This commit is contained in:
2026-02-22 20:25:41 -06:00
parent fca56ff8aa
commit 351393dc16
23 changed files with 823 additions and 81 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.github
.env
.env.*
!.env.example
web
node_modules
bin
*.md
*.test
*.out
coverage.*
docker-compose*.yml

View File

@@ -5,10 +5,26 @@
# Server
PORT=8080
ENVIRONMENT=development
HOST=0.0.0.0
ENVIRONMENT=development # development | production
# PostgreSQL
# Timeouts
READ_TIMEOUT=5s
WRITE_TIMEOUT=10s
IDLE_TIMEOUT=120s
# PostgreSQL (used by Docker Compose)
POSTGRES_USER=pulsescore
POSTGRES_PASSWORD=pulsescore
POSTGRES_DB=pulsescore_dev
# Database connection (used by the Go API)
DATABASE_URL=postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable
DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5
# CORS — comma-separated list of allowed origins
CORS_ALLOWED_ORIGINS=http://localhost:5173
# Rate limiting — requests per minute per IP
RATE_LIMIT_RPM=100

70
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
go:
name: Go (lint, test, build)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: pulsescore
POSTGRES_PASSWORD: pulsescore
POSTGRES_DB: pulsescore_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U pulsescore -d pulsescore_test"
--health-interval 5s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
- name: Test
env:
DATABASE_URL: postgres://pulsescore:pulsescore@localhost:5432/pulsescore_test?sslmode=disable
run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
- name: Build
run: go build -o bin/pulsescore-api ./cmd/api
react:
name: React (lint, build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o pulsescore-api ./cmd/api
# =============================================================================
# Stage 2: Runtime
# =============================================================================
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -g 1001 -S appgroup \
&& adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /build/pulsescore-api .
COPY --from=builder /build/migrations ./migrations
USER appuser:appgroup
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
ENTRYPOINT ["./pulsescore-api"]

View File

@@ -1,7 +1,8 @@
.PHONY: build run test lint clean migrate-up migrate-down
.PHONY: build run test lint clean migrate-up migrate-down migrate-create dev-db dev-db-down
BINARY_NAME=pulsescore-api
BUILD_DIR=bin
DATABASE_URL ?= postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable
build:
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/api
@@ -19,12 +20,14 @@ clean:
rm -rf $(BUILD_DIR)
migrate-up:
@echo "Run migrations up (requires golang-migrate)"
migrate -path migrations -database "$(DATABASE_URL)" up
migrate-down:
@echo "Run migrations down (requires golang-migrate)"
migrate -path migrations -database "$(DATABASE_URL)" down
migrate -path migrations -database "$(DATABASE_URL)" down 1
migrate-create:
@if [ -z "$(NAME)" ]; then echo "Usage: make migrate-create NAME=my_migration"; exit 1; fi
migrate create -ext sql -dir migrations -seq $(NAME)
dev-db:
docker compose -f docker-compose.dev.yml up -d

View File

@@ -76,23 +76,23 @@ The frontend starts on http://localhost:5173.
### Backend (Makefile)
| Command | Description |
| ------------------ | ------------------------------ |
| `make build` | Build the Go binary |
| `make run` | Build and run the API |
| `make test` | Run tests with race detection |
| `make lint` | Run `go vet` |
| `make dev-db` | Start development PostgreSQL |
| `make dev-db-down` | Stop development PostgreSQL |
| `make migrate-up` | Run database migrations up |
| `make migrate-down`| Roll back database migrations |
| Command | Description |
| ------------------- | ----------------------------- |
| `make build` | Build the Go binary |
| `make run` | Build and run the API |
| `make test` | Run tests with race detection |
| `make lint` | Run `go vet` |
| `make dev-db` | Start development PostgreSQL |
| `make dev-db-down` | Stop development PostgreSQL |
| `make migrate-up` | Run database migrations up |
| `make migrate-down` | Roll back database migrations |
### Frontend (web/)
| Command | Description |
| ------------------- | ----------------------------- |
| `npm run dev` | Start Vite dev server |
| `npm run build` | Production build |
| `npm run lint` | ESLint check |
| `npm run format` | Format with Prettier |
| `npm run preview` | Preview production build |
| Command | Description |
| ----------------- | ------------------------ |
| `npm run dev` | Start Vite dev server |
| `npm run build` | Production build |
| `npm run lint` | ESLint check |
| `npm run format` | Format with Prettier |
| `npm run preview` | Preview production build |

View File

@@ -1,34 +1,128 @@
package main
import (
"context"
"database/sql"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
_ "github.com/lib/pq"
"github.com/onnwee/pulse-score/internal/config"
"github.com/onnwee/pulse-score/internal/handler"
"github.com/onnwee/pulse-score/internal/middleware"
)
func main() {
// Structured logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Configuration
cfg := config.Load()
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"ok"}`)
})
addr := fmt.Sprintf(":%s", cfg.Port)
log.Printf("Starting PulseScore API on %s", addr)
srv := &http.Server{
Addr: addr,
Handler: mux,
}
if err := srv.ListenAndServe(); err != nil {
log.Printf("Server error: %v", err)
if err := cfg.Validate(); err != nil {
slog.Error("invalid configuration", "error", err)
os.Exit(1)
}
// Database connection (optional — server starts without DB for healthz)
var db *sql.DB
if cfg.Database.URL != "" {
var err error
db, err = sql.Open("postgres", cfg.Database.URL)
if err != nil {
slog.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
slog.Warn("database not reachable at startup", "error", err)
} else {
slog.Info("database connected")
}
}
// Router
r := chi.NewRouter()
// Global middleware
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(middleware.SecurityHeaders)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.CORS.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(httprate.LimitByIP(cfg.Rate.RequestsPerMinute, time.Minute))
// Health checks (no auth required)
health := handler.NewHealthHandler(db)
r.Get("/healthz", health.Liveness)
r.Get("/readyz", health.Readiness)
// API v1 route group
r.Route("/api/v1", func(r chi.Router) {
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"pong"}`))
})
})
// Server
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
}
// Graceful shutdown
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
slog.Info("starting PulseScore API", "addr", addr, "env", cfg.Server.Environment)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
<-done
slog.Info("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server forced to shutdown", "error", err)
os.Exit(1)
}
slog.Info("server stopped")
}

59
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,59 @@
services:
db:
image: postgres:16-alpine
container_name: pulsescore-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/init-db:/docker-entrypoint-initdb.d
networks:
- pulsescore-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
api:
build:
context: .
dockerfile: Dockerfile
container_name: pulsescore-api
restart: unless-stopped
ports:
- "${PORT:-8080}:8080"
environment:
PORT: "8080"
ENVIRONMENT: production
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
depends_on:
db:
condition: service_healthy
networks:
- pulsescore-net
web:
build:
context: ./web
dockerfile: Dockerfile
container_name: pulsescore-web
restart: unless-stopped
ports:
- "80:80"
depends_on:
- api
networks:
- pulsescore-net
volumes:
pgdata:
networks:
pulsescore-net:
driver: bridge

14
go.mod
View File

@@ -1,3 +1,17 @@
module github.com/onnwee/pulse-score
go 1.24.3
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/lib/pq v1.10.9
)
require (
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
)

18
go.sum Normal file
View File

@@ -0,0 +1,18 @@
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

View File

@@ -1,26 +1,162 @@
package config
import "os"
import (
"fmt"
"os"
"strconv"
"time"
)
// Config holds application configuration.
type Config struct {
Port string
DatabaseURL string
Environment string
Server ServerConfig
Database DatabaseConfig
CORS CORSConfig
Rate RateConfig
}
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Port string
Host string
Environment string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
// DatabaseConfig holds database connection settings.
type DatabaseConfig struct {
URL string
MaxOpenConns int
MaxIdleConns int
}
// CORSConfig holds CORS middleware settings.
type CORSConfig struct {
AllowedOrigins []string
}
// RateConfig holds rate limiting settings.
type RateConfig struct {
RequestsPerMinute int
}
// IsProd returns true if the environment is production.
func (c *Config) IsProd() bool {
return c.Server.Environment == "production"
}
// Load reads configuration from environment variables with sensible defaults.
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable"),
Environment: getEnv("ENVIRONMENT", "development"),
Server: ServerConfig{
Port: getEnv("PORT", "8080"),
Host: getEnv("HOST", "0.0.0.0"),
Environment: getEnv("ENVIRONMENT", "development"),
ReadTimeout: getDuration("READ_TIMEOUT", 5*time.Second),
WriteTimeout: getDuration("WRITE_TIMEOUT", 10*time.Second),
IdleTimeout: getDuration("IDLE_TIMEOUT", 120*time.Second),
},
Database: DatabaseConfig{
URL: getEnv("DATABASE_URL", "postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable"),
MaxOpenConns: getInt("DB_MAX_OPEN_CONNS", 25),
MaxIdleConns: getInt("DB_MAX_IDLE_CONNS", 5),
},
CORS: CORSConfig{
AllowedOrigins: getEnvSlice("CORS_ALLOWED_ORIGINS", []string{"http://localhost:5173"}),
},
Rate: RateConfig{
RequestsPerMinute: getInt("RATE_LIMIT_RPM", 100),
},
}
}
// Validate checks required configuration for production.
func (c *Config) Validate() error {
if c.IsProd() {
if c.Database.URL == "" {
return fmt.Errorf("DATABASE_URL is required in production")
}
}
return nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
i, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return i
}
func getDuration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}
func getEnvSlice(key string, fallback []string) []string {
v := os.Getenv(key)
if v == "" {
return fallback
}
var result []string
for _, s := range splitAndTrim(v) {
if s != "" {
result = append(result, s)
}
}
if len(result) == 0 {
return fallback
}
return result
}
func splitAndTrim(s string) []string {
var parts []string
for _, p := range []byte(s) {
if p == ',' {
parts = append(parts, "")
} else {
if len(parts) == 0 {
parts = append(parts, "")
}
parts[len(parts)-1] += string(p)
}
}
// Trim spaces
for i, p := range parts {
trimmed := ""
start := 0
end := len(p) - 1
for start <= end && p[start] == ' ' {
start++
}
for end >= start && p[end] == ' ' {
end--
}
if start <= end {
trimmed = p[start : end+1]
}
parts[i] = trimmed
}
return parts
}

View File

@@ -3,38 +3,114 @@ package config
import (
"os"
"testing"
"time"
)
func clearEnv() {
for _, key := range []string{
"PORT", "HOST", "ENVIRONMENT", "DATABASE_URL",
"DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS",
"READ_TIMEOUT", "WRITE_TIMEOUT", "IDLE_TIMEOUT",
"CORS_ALLOWED_ORIGINS", "RATE_LIMIT_RPM",
} {
os.Unsetenv(key)
}
}
func TestLoadDefaults(t *testing.T) {
// Unset any env vars that might be set
os.Unsetenv("PORT")
os.Unsetenv("DATABASE_URL")
os.Unsetenv("ENVIRONMENT")
clearEnv()
cfg := Load()
if cfg.Port != "8080" {
t.Errorf("expected default port 8080, got %s", cfg.Port)
if cfg.Server.Port != "8080" {
t.Errorf("expected default port 8080, got %s", cfg.Server.Port)
}
if cfg.Environment != "development" {
t.Errorf("expected default environment development, got %s", cfg.Environment)
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("expected default host 0.0.0.0, got %s", cfg.Server.Host)
}
if cfg.Server.Environment != "development" {
t.Errorf("expected default environment development, got %s", cfg.Server.Environment)
}
if cfg.Server.ReadTimeout != 5*time.Second {
t.Errorf("expected read timeout 5s, got %v", cfg.Server.ReadTimeout)
}
if cfg.Server.WriteTimeout != 10*time.Second {
t.Errorf("expected write timeout 10s, got %v", cfg.Server.WriteTimeout)
}
if cfg.Database.MaxOpenConns != 25 {
t.Errorf("expected max open conns 25, got %d", cfg.Database.MaxOpenConns)
}
if cfg.Database.MaxIdleConns != 5 {
t.Errorf("expected max idle conns 5, got %d", cfg.Database.MaxIdleConns)
}
if cfg.Rate.RequestsPerMinute != 100 {
t.Errorf("expected rate limit 100, got %d", cfg.Rate.RequestsPerMinute)
}
if len(cfg.CORS.AllowedOrigins) != 1 || cfg.CORS.AllowedOrigins[0] != "http://localhost:5173" {
t.Errorf("expected default CORS origin, got %v", cfg.CORS.AllowedOrigins)
}
}
func TestLoadFromEnv(t *testing.T) {
clearEnv()
os.Setenv("PORT", "3000")
os.Setenv("ENVIRONMENT", "production")
defer func() {
os.Unsetenv("PORT")
os.Unsetenv("ENVIRONMENT")
}()
os.Setenv("CORS_ALLOWED_ORIGINS", "https://example.com, https://app.example.com")
os.Setenv("RATE_LIMIT_RPM", "200")
defer clearEnv()
cfg := Load()
if cfg.Port != "3000" {
t.Errorf("expected port 3000, got %s", cfg.Port)
if cfg.Server.Port != "3000" {
t.Errorf("expected port 3000, got %s", cfg.Server.Port)
}
if cfg.Environment != "production" {
t.Errorf("expected environment production, got %s", cfg.Environment)
if cfg.Server.Environment != "production" {
t.Errorf("expected environment production, got %s", cfg.Server.Environment)
}
if len(cfg.CORS.AllowedOrigins) != 2 {
t.Errorf("expected 2 CORS origins, got %d", len(cfg.CORS.AllowedOrigins))
}
if cfg.Rate.RequestsPerMinute != 200 {
t.Errorf("expected rate limit 200, got %d", cfg.Rate.RequestsPerMinute)
}
}
func TestValidateProduction(t *testing.T) {
clearEnv()
os.Setenv("ENVIRONMENT", "production")
os.Setenv("DATABASE_URL", "")
defer clearEnv()
cfg := Load()
// In production with empty DATABASE_URL env var, it will use fallback
// Explicitly clear it to test validation
cfg.Database.URL = ""
if err := cfg.Validate(); err == nil {
t.Error("expected validation error for missing DATABASE_URL in production")
}
}
func TestValidateDevelopment(t *testing.T) {
clearEnv()
cfg := Load()
if err := cfg.Validate(); err != nil {
t.Errorf("expected no validation error in development, got %v", err)
}
}
func TestIsProd(t *testing.T) {
clearEnv()
cfg := Load()
if cfg.IsProd() {
t.Error("expected IsProd false in development")
}
os.Setenv("ENVIRONMENT", "production")
defer clearEnv()
cfg = Load()
if !cfg.IsProd() {
t.Error("expected IsProd true in production")
}
}

View File

@@ -0,0 +1,53 @@
package handler
import (
"database/sql"
"encoding/json"
"net/http"
)
// HealthHandler provides health check endpoints.
type HealthHandler struct {
db *sql.DB
}
// NewHealthHandler creates a new HealthHandler.
func NewHealthHandler(db *sql.DB) *HealthHandler {
return &HealthHandler{db: db}
}
// Liveness always returns 200 — the server is alive.
func (h *HealthHandler) Liveness(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// Readiness returns 200 if the database is reachable, 503 otherwise.
func (h *HealthHandler) Readiness(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if h.db == nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"status": "degraded",
"db": "not configured",
})
return
}
if err := h.db.PingContext(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"status": "degraded",
"db": "unreachable",
})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"db": "connected",
})
}

View File

@@ -0,0 +1,44 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestLiveness(t *testing.T) {
h := NewHealthHandler(nil)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rr := httptest.NewRecorder()
h.Liveness(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
}
var body map[string]string
json.NewDecoder(rr.Body).Decode(&body)
if body["status"] != "ok" {
t.Errorf("expected status ok, got %s", body["status"])
}
}
func TestReadinessNoDatabase(t *testing.T) {
h := NewHealthHandler(nil)
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rr := httptest.NewRecorder()
h.Readiness(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("expected status 503, got %d", rr.Code)
}
var body map[string]string
json.NewDecoder(rr.Body).Decode(&body)
if body["status"] != "degraded" {
t.Errorf("expected status degraded, got %s", body["status"])
}
}

View File

@@ -0,0 +1,15 @@
package middleware
import "net/http"
// SecurityHeaders adds common security headers to all responses.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,32 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
expected := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "0",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'self'",
}
for header, want := range expected {
got := rr.Header().Get(header)
if got != want {
t.Errorf("header %s = %q, want %q", header, got, want)
}
}
}

View File

View File

@@ -0,0 +1,2 @@
DROP EXTENSION IF EXISTS "citext";
DROP EXTENSION IF EXISTS "uuid-ossp";

View File

@@ -0,0 +1,2 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";

27
web/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM node:22-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# =============================================================================
# Stage 2: Serve with Nginx
# =============================================================================
FROM nginx:alpine
# Remove default config
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,15 +1,15 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
@@ -22,4 +22,4 @@ export default defineConfig([
},
},
eslintConfigPrettier,
])
]);

31
web/nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache hashed assets aggressively (1 year)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View File

@@ -1,14 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
"@": path.resolve(__dirname, "./src"),
},
},
})
});