// Package config loads runtime configuration from the environment. // // Every setting has a safe local default — the database is a local SQLite // file, so the server runs with `make migrate-up && make run` and no .env // file, no containers. Point DATABASE_URL at Postgres for the real thing. // This is the only package that reads os.Getenv — add new settings here // and to .env.example, never at the point of use. package config import ( "crypto/rand" "encoding/base64" "errors" "fmt" "log/slog" "net/url" "os" "strings" "time" "git.k6n.net/mats/fullstack-template/internal/browse" ) // Config is the validated runtime configuration. type Config struct { // Addr is the HTTP listen address. Addr string // DatabaseURL selects the database: "sqlite:FILE" for a local file // (dev default, pure-Go driver) or a "postgres://…" DSN. DatabaseURL string // AllowedOrigins lists CORS origins permitted to call the API from a // browser (the Vite dev server on :5173 by default). "*" allows all. AllowedOrigins []string // LogLevel and LogFormat configure the slog handler. LogLevel slog.Level LogFormat string // "json" or "text" // DebugQueries enables Bun query logging (bundebug). DebugQueries bool // FetchUserAgent identifies this server to the sites it inspects. FetchUserAgent string // FetchTimeout bounds each outbound fetch, redirects included. FetchTimeout time.Duration // OIDCIssuer is the OpenID Connect issuer; endpoints are discovered, // never hardcoded. Auth is enabled when OIDCClientID is non-empty. OIDCIssuer string // OIDCClientID and OIDCClientSecret identify this app as a confidential // client of the issuer. An empty OIDCClientID disables auth entirely: // /api/auth/* answers 404 and /api/v1 stays open (standalone mode). OIDCClientID string OIDCClientSecret string // AuthSessionSecret signs session cookies. When unset, a random // ephemeral secret is generated (AuthSessionSecretEphemeral = true) — // every restart then invalidates all sessions. AuthSessionSecret string AuthSessionSecretEphemeral bool // AuthSessionTTL is how long a session cookie stays valid. AuthSessionTTL time.Duration } // Load reads the environment and returns a validated Config. func Load() (Config, error) { cfg := Config{ Addr: env("API_ADDR", ":8080"), DatabaseURL: env("DATABASE_URL", "sqlite:dev.db"), AllowedOrigins: splitCSV(env("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")), LogFormat: strings.ToLower(env("LOG_FORMAT", "json")), DebugQueries: env("DB_DEBUG", "false") == "true", FetchUserAgent: env("FETCH_USER_AGENT", browse.DefaultUserAgent), OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", "https://users.k6n.net"), "/"), OIDCClientID: env("OIDC_CLIENT_ID", ""), OIDCClientSecret: env("OIDC_CLIENT_SECRET", ""), AuthSessionSecret: env("AUTH_SESSION_SECRET", ""), } sessionTTL, ttlErr := time.ParseDuration(env("AUTH_SESSION_TTL", "168h")) if ttlErr == nil { cfg.AuthSessionTTL = sessionTTL } fetchTimeout, fetchTimeoutErr := time.ParseDuration(env("FETCH_TIMEOUT", "15s")) var errs []error level := strings.ToLower(env("LOG_LEVEL", "info")) switch level { case "debug": cfg.LogLevel = slog.LevelDebug case "info": cfg.LogLevel = slog.LevelInfo case "warn": cfg.LogLevel = slog.LevelWarn case "error": cfg.LogLevel = slog.LevelError default: errs = append(errs, fmt.Errorf("invalid LOG_LEVEL %q (want debug, info, warn, or error)", level)) } if cfg.Addr == "" { errs = append(errs, errors.New("API_ADDR must not be empty")) } if cfg.DatabaseURL == "" { errs = append(errs, errors.New("DATABASE_URL must not be empty")) } if cfg.LogFormat != "json" && cfg.LogFormat != "text" { errs = append(errs, fmt.Errorf("invalid LOG_FORMAT %q (want json or text)", cfg.LogFormat)) } if ttlErr != nil { errs = append(errs, fmt.Errorf("invalid AUTH_SESSION_TTL: %v", ttlErr)) } else if cfg.AuthSessionTTL <= 0 { errs = append(errs, errors.New("AUTH_SESSION_TTL must be positive")) } if fetchTimeoutErr != nil { errs = append(errs, fmt.Errorf("invalid FETCH_TIMEOUT: %v", fetchTimeoutErr)) } else if fetchTimeout <= 0 { errs = append(errs, errors.New("FETCH_TIMEOUT must be positive")) } else { cfg.FetchTimeout = fetchTimeout } if _, err := url.Parse(cfg.OIDCIssuer); err != nil || !strings.HasPrefix(cfg.OIDCIssuer, "https://") && !strings.HasPrefix(cfg.OIDCIssuer, "http://") { errs = append(errs, fmt.Errorf("invalid OIDC_ISSUER %q (want an http(s) URL)", cfg.OIDCIssuer)) } if cfg.OIDCClientID != "" && cfg.OIDCClientSecret == "" { errs = append(errs, errors.New("OIDC_CLIENT_SECRET is required when OIDC_CLIENT_ID is set")) } if cfg.AuthSessionSecret == "" { secret, err := randomSecret() if err != nil { return Config{}, fmt.Errorf("generate session secret: %w", err) } cfg.AuthSessionSecret = secret cfg.AuthSessionSecretEphemeral = true } if len(errs) > 0 { return Config{}, fmt.Errorf("invalid config: %w", errors.Join(errs...)) } return cfg, nil } // randomSecret returns a URL-safe random string for signing session // cookies when AUTH_SESSION_SECRET is not provided. func randomSecret() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // env returns the environment variable or fallback when unset/blank. func env(key, fallback string) string { if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } return fallback } // splitCSV splits a comma-separated env value, dropping blank entries. func splitCSV(v string) []string { parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }