Initial commit
This commit is contained in:
commit
afed023152
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,155 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// 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",
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 _, 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
|
||||
}
|
||||
Reference in new issue
Block a user