Files
2026-10-19 15:25:27 +00:00

172 lines
5.8 KiB
Go

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