172 lines
5.8 KiB
Go
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
|
|
}
|