initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
572
internal/config/config.go
Normal file
572
internal/config/config.go
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
// Package config loads and validates every runtime setting from the
|
||||
// environment.
|
||||
//
|
||||
// Configuration is environment-only on purpose. The Helm chart is the single
|
||||
// source of truth for how a deployment is configured; letting the binary also
|
||||
// read a config file would create a second source that operators forget about.
|
||||
// Every value has a safe default except the secrets and the OIDC issuer, which
|
||||
// must be supplied explicitly when auth is enabled.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment prefixes, tried in order. This lets the Helm chart mount a
|
||||
// ConfigMap under a distinct prefix without colliding with the pod's own
|
||||
// environment, which is what makes one image usable across environments.
|
||||
var envPrefixes = []string{"BANANAS_", "GO_BANANAS_"}
|
||||
|
||||
// Config is the fully resolved runtime configuration.
|
||||
type Config struct {
|
||||
// Environment is the deployment name, used in logs and the admin UI.
|
||||
Environment string
|
||||
// LogLevel is one of debug, info, warn, error.
|
||||
LogLevel string
|
||||
// LogFormat is json or text.
|
||||
LogFormat string
|
||||
|
||||
// HTTPAddr is the listen address, host:port.
|
||||
HTTPAddr string
|
||||
// PublicBaseURL is the externally reachable origin, without a trailing
|
||||
// slash. It is used to build absolute URLs in the runtime manifest and to
|
||||
// validate OIDC redirect targets.
|
||||
PublicBaseURL string
|
||||
// TrustedProxies lists the CIDRs or IPs whose X-Forwarded-For headers may be
|
||||
// believed. It is empty by default, which makes the service ignore
|
||||
// forwarding headers entirely and log the direct peer.
|
||||
TrustedProxies []string
|
||||
// ReadTimeout and WriteTimeout bound a single request. WriteTimeout is
|
||||
// generous because the runtime CSS endpoint is on the hot path of every page
|
||||
// load in every consuming application.
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
// ShutdownTimeout is how long in-flight requests get to finish on SIGTERM.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// CORSAllowedOrigins are the browser origins allowed to call the admin API
|
||||
// from a different origin. The runtime API is always public and is not
|
||||
// subject to this list.
|
||||
CORSAllowedOrigins []string
|
||||
// RateLimitPerMinute caps requests per client for the runtime API.
|
||||
RateLimitPerMinute int
|
||||
|
||||
// DatabasePath is the SQLite file path. Use ":memory:" for tests.
|
||||
DatabasePath string
|
||||
// DatabaseBusyTimeout is how long a writer waits for the database lock
|
||||
// before failing.
|
||||
DatabaseBusyTimeout time.Duration
|
||||
// DatabaseMaxConns bounds the connection pool. SQLite serialises writers, so
|
||||
// a small pool with WAL is faster than a large one.
|
||||
DatabaseMaxConns int
|
||||
|
||||
// AuthMode is oidc, dev, or none.
|
||||
AuthMode string
|
||||
// OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi.
|
||||
OIDCIssuer string
|
||||
// OIDCClientID is the client identifier registered with the provider.
|
||||
OIDCClientID string
|
||||
// OIDCClientSecret authenticates the confidential client.
|
||||
OIDCClientSecret string
|
||||
// OIDCScopes are requested in addition to openid.
|
||||
OIDCScopes []string
|
||||
// OIDCRedirectURL overrides the computed callback URL. It is only needed
|
||||
// when the public URL cannot be derived, for example behind a path-based
|
||||
// ingress.
|
||||
OIDCRedirectURL string
|
||||
// OIDCPostLogoutRedirectURL is where the provider sends the browser after
|
||||
// logout. It must be an allowed post-logout target on the provider side.
|
||||
OIDCPostLogoutRedirectURL string
|
||||
// OIDCAdminGroups are the group names that map to the admin role.
|
||||
OIDCAdminGroups []string
|
||||
// OIDCEditorGroups are the group names that map to the editor role.
|
||||
OIDCEditorGroups []string
|
||||
// OIDCViewerGroups are the group names that map to the viewer role.
|
||||
OIDCViewerGroups []string
|
||||
// OIDCSkipTLSVerify disables provider certificate verification. It exists
|
||||
// only so that a self-signed local provider can be used during development
|
||||
// and it is refused outside the development environment.
|
||||
OIDCSkipTLSVerify bool
|
||||
|
||||
// SessionSecret signs the session cookie. It must be at least 32 bytes and
|
||||
// is required whenever auth is enabled.
|
||||
SessionSecret string
|
||||
// SessionCookieName is the cookie carrying the session.
|
||||
SessionCookieName string
|
||||
// SessionSecure marks the session cookie Secure.
|
||||
SessionSecure bool
|
||||
// SessionTTL is the session lifetime.
|
||||
SessionTTL time.Duration
|
||||
// SessionSameSite is Lax, Strict or None.
|
||||
SessionSameSite string
|
||||
|
||||
// RuntimePublic controls whether the runtime API requires a credential. It
|
||||
// is enabled by default because a theming service's whole purpose is to be
|
||||
// reachable from browsers and CDN caches.
|
||||
RuntimePublic bool
|
||||
// RuntimeCacheControl is the Cache-Control header sent on runtime assets.
|
||||
// It is tuned for aggressive CDN caching with a revalidation window that
|
||||
// matches how often themes are published.
|
||||
RuntimeCacheControl string
|
||||
// RuntimeCDNBaseURL, when set, is used as the absolute base in the manifest
|
||||
// so consumers fetch assets from a CDN rather than from this service.
|
||||
RuntimeCDNBaseURL string
|
||||
|
||||
// SeedOnStart reconciles the built-in themes at start-up.
|
||||
SeedOnStart bool
|
||||
// DevFallbackRole is the role granted by the development auth bypass.
|
||||
DevFallbackRole string
|
||||
// DevUsers maps a development email to a role, overriding DevFallbackRole.
|
||||
DevUsers map[string]string
|
||||
|
||||
// EnableMetrics exposes /metrics in Prometheus format.
|
||||
EnableMetrics bool
|
||||
// EnableProfiler exposes the net/http/pprof handlers under /debug/pprof.
|
||||
EnableProfiler bool
|
||||
// StaticDir is the directory holding the built admin UI. When empty the UI
|
||||
// is not served.
|
||||
StaticDir string
|
||||
// SDKDir is the directory holding the built runtime SDK bundles.
|
||||
SDKDir string
|
||||
// BasePath prefixes every route, for deployments behind a path-based ingress.
|
||||
BasePath string
|
||||
|
||||
// ephemeralSessionSecret records that the secret was generated rather than
|
||||
// supplied. It is not part of the configuration surface; the application reads
|
||||
// it only to decide whether to warn.
|
||||
ephemeralSessionSecret bool
|
||||
}
|
||||
|
||||
// Auth mode values.
|
||||
const (
|
||||
AuthModeOIDC = "oidc"
|
||||
AuthModeDev = "dev"
|
||||
AuthModeNone = "none"
|
||||
)
|
||||
|
||||
// Defaults returns a Config populated with production-safe defaults. Load
|
||||
// applies the environment on top of this value.
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
Environment: "development",
|
||||
LogLevel: "info",
|
||||
LogFormat: "text",
|
||||
|
||||
HTTPAddr: ":8080",
|
||||
PublicBaseURL: "http://localhost:8080",
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ShutdownTimeout: 25 * time.Second,
|
||||
|
||||
CORSAllowedOrigins: []string{},
|
||||
RateLimitPerMinute: 3000,
|
||||
|
||||
DatabasePath: "./data/go-bananas.db",
|
||||
DatabaseBusyTimeout: 5 * time.Second,
|
||||
DatabaseMaxConns: 8,
|
||||
|
||||
AuthMode: AuthModeDev,
|
||||
OIDCScopes: []string{"profile", "email", "groups"},
|
||||
SessionCookieName: "go-bananas-session",
|
||||
SessionSecure: false,
|
||||
SessionTTL: 8 * time.Hour,
|
||||
SessionSameSite: "Lax",
|
||||
RuntimePublic: true,
|
||||
RuntimeCacheControl: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
|
||||
SeedOnStart: true,
|
||||
DevFallbackRole: "admin",
|
||||
DevUsers: map[string]string{},
|
||||
EnableMetrics: true,
|
||||
EnableProfiler: false,
|
||||
StaticDir: "./web/dist",
|
||||
SDKDir: "./sdk/dist",
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment and validates it.
|
||||
func Load() (Config, error) {
|
||||
return loadFrom(os.LookupEnv)
|
||||
}
|
||||
|
||||
// loadFrom is the testable core of Load.
|
||||
func loadFrom(lookup func(string) (string, bool)) (Config, error) {
|
||||
cfg := Defaults()
|
||||
|
||||
get := func(name string) (string, bool) {
|
||||
for _, prefix := range envPrefixes {
|
||||
if v, ok := lookup(prefix + name); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
str := func(name string, target *string) {
|
||||
if v, ok := get(name); ok {
|
||||
*target = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Environment = firstNonEmpty(cfg.Environment,
|
||||
func() (string, bool) { return get("ENVIRONMENT") },
|
||||
func() (string, bool) { return get("ENV") },
|
||||
)
|
||||
str("LOG_LEVEL", &cfg.LogLevel)
|
||||
str("LOG_FORMAT", &cfg.LogFormat)
|
||||
|
||||
str("HTTP_ADDR", &cfg.HTTPAddr)
|
||||
str("PUBLIC_BASE_URL", &cfg.PublicBaseURL)
|
||||
str("BASE_PATH", &cfg.BasePath)
|
||||
str("STATIC_DIR", &cfg.StaticDir)
|
||||
str("SDK_DIR", &cfg.SDKDir)
|
||||
|
||||
dur := func(name string, target *time.Duration) error {
|
||||
v, ok := get(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*target = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
num := func(name string, target *int) error {
|
||||
v, ok := get(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*target = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
boolean := func(name string, target *bool) error {
|
||||
v, ok := get(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*target = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
list := func(name string, target *[]string) {
|
||||
if v, ok := get(name); ok {
|
||||
*target = splitList(v)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
for _, f := range []func() error{
|
||||
func() error { return dur("HTTP_READ_TIMEOUT", &cfg.ReadTimeout) },
|
||||
func() error { return dur("HTTP_WRITE_TIMEOUT", &cfg.WriteTimeout) },
|
||||
func() error { return dur("HTTP_IDLE_TIMEOUT", &cfg.IdleTimeout) },
|
||||
func() error { return dur("SHUTDOWN_TIMEOUT", &cfg.ShutdownTimeout) },
|
||||
func() error { return dur("SESSION_TTL", &cfg.SessionTTL) },
|
||||
func() error { return dur("DB_BUSY_TIMEOUT", &cfg.DatabaseBusyTimeout) },
|
||||
func() error { return num("RATE_LIMIT_PER_MINUTE", &cfg.RateLimitPerMinute) },
|
||||
func() error { return num("DB_MAX_CONNS", &cfg.DatabaseMaxConns) },
|
||||
func() error { return boolean("RUNTIME_PUBLIC", &cfg.RuntimePublic) },
|
||||
func() error { return boolean("SEED_ON_START", &cfg.SeedOnStart) },
|
||||
func() error { return boolean("ENABLE_METRICS", &cfg.EnableMetrics) },
|
||||
func() error { return boolean("ENABLE_PROFILER", &cfg.EnableProfiler) },
|
||||
func() error { return boolean("SESSION_SECURE", &cfg.SessionSecure) },
|
||||
func() error { return boolean("OIDC_SKIP_TLS_VERIFY", &cfg.OIDCSkipTLSVerify) },
|
||||
} {
|
||||
if err = f(); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
|
||||
list("TRUSTED_PROXIES", &cfg.TrustedProxies)
|
||||
list("CORS_ALLOWED_ORIGINS", &cfg.CORSAllowedOrigins)
|
||||
list("OIDC_SCOPES", &cfg.OIDCScopes)
|
||||
list("OIDC_ADMIN_GROUPS", &cfg.OIDCAdminGroups)
|
||||
list("OIDC_EDITOR_GROUPS", &cfg.OIDCEditorGroups)
|
||||
list("OIDC_VIEWER_GROUPS", &cfg.OIDCViewerGroups)
|
||||
|
||||
str("AUTH_MODE", &cfg.AuthMode)
|
||||
str("OIDC_ISSUER", &cfg.OIDCIssuer)
|
||||
str("OIDC_CLIENT_ID", &cfg.OIDCClientID)
|
||||
str("OIDC_CLIENT_SECRET", &cfg.OIDCClientSecret)
|
||||
str("OIDC_REDIRECT_URL", &cfg.OIDCRedirectURL)
|
||||
str("OIDC_POST_LOGOUT_REDIRECT_URL", &cfg.OIDCPostLogoutRedirectURL)
|
||||
str("SESSION_SECRET", &cfg.SessionSecret)
|
||||
str("SESSION_COOKIE_NAME", &cfg.SessionCookieName)
|
||||
str("SESSION_SAMESITE", &cfg.SessionSameSite)
|
||||
str("DEV_FALLBACK_ROLE", &cfg.DevFallbackRole)
|
||||
str("RUNTIME_CACHE_CONTROL", &cfg.RuntimeCacheControl)
|
||||
str("RUNTIME_CDN_BASE_URL", &cfg.RuntimeCDNBaseURL)
|
||||
str("DATABASE_PATH", &cfg.DatabasePath)
|
||||
|
||||
if v, ok := get("DEV_USERS"); ok {
|
||||
cfg.DevUsers = parseDevUsers(v)
|
||||
}
|
||||
|
||||
// A development run should not require the operator to invent a 32-byte
|
||||
// secret before the first request. An ephemeral one is generated instead,
|
||||
// which is safe because nothing durable is signed with it: sessions simply do
|
||||
// not survive a restart, which a local run never notices.
|
||||
if cfg.AuthMode != AuthModeNone && cfg.SessionSecret == "" && cfg.IsDevelopment() {
|
||||
generated, genErr := randomSecret()
|
||||
if genErr != nil {
|
||||
return cfg, genErr
|
||||
}
|
||||
cfg.SessionSecret = generated
|
||||
cfg.ephemeralSessionSecret = true
|
||||
}
|
||||
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
// EphemeralSessionSecret reports whether the session secret was generated rather
|
||||
// than supplied. The caller logs it as a warning, because it means sessions
|
||||
// survive only until the process restarts.
|
||||
func (c Config) EphemeralSessionSecret() bool { return c.ephemeralSessionSecret }
|
||||
|
||||
// randomSecret returns a 48-byte cryptographically random value, base64 encoded.
|
||||
func randomSecret() (string, error) {
|
||||
buf := make([]byte, 48)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate session secret: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Validate checks the configuration for internal consistency. It returns every
|
||||
// problem it finds rather than only the first, because fixing configuration one
|
||||
// error per deploy is a miserable loop.
|
||||
func (c Config) Validate() error {
|
||||
var problems []string
|
||||
add := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
switch c.AuthMode {
|
||||
case AuthModeOIDC, AuthModeDev, AuthModeNone:
|
||||
default:
|
||||
add("AUTH_MODE %q must be one of oidc, dev or none", c.AuthMode)
|
||||
}
|
||||
|
||||
if c.AuthMode == AuthModeOIDC {
|
||||
if c.OIDCIssuer == "" {
|
||||
add("OIDC_ISSUER is required when AUTH_MODE=oidc")
|
||||
} else if parsed, err := url.Parse(c.OIDCIssuer); err != nil {
|
||||
add("OIDC_ISSUER is not a valid URL: %v", err)
|
||||
} else if parsed.Scheme != "https" && !isLocalHost(parsed.Hostname()) {
|
||||
add("OIDC_ISSUER must use https outside local development, got %q", parsed.Scheme)
|
||||
}
|
||||
if c.OIDCClientID == "" {
|
||||
add("OIDC_CLIENT_ID is required when AUTH_MODE=oidc")
|
||||
}
|
||||
if c.OIDCClientSecret == "" {
|
||||
add("OIDC_CLIENT_SECRET is required when AUTH_MODE=oidc")
|
||||
}
|
||||
if c.OIDCSkipTLSVerify && !c.IsDevelopment() {
|
||||
add("OIDC_SKIP_TLS_VERIFY is refused outside development; it disables certificate verification on the identity provider connection")
|
||||
}
|
||||
}
|
||||
|
||||
if c.AuthMode != AuthModeNone {
|
||||
if len(c.SessionSecret) < 32 {
|
||||
add("SESSION_SECRET must be at least 32 bytes when auth is enabled, got %d", len(c.SessionSecret))
|
||||
}
|
||||
}
|
||||
|
||||
if c.PublicBaseURL != "" {
|
||||
parsed, err := url.Parse(c.PublicBaseURL)
|
||||
switch {
|
||||
case err != nil:
|
||||
add("PUBLIC_BASE_URL is not a valid URL: %v", err)
|
||||
case parsed.Scheme == "":
|
||||
add("PUBLIC_BASE_URL must include a scheme, got %q", c.PublicBaseURL)
|
||||
case parsed.Host == "":
|
||||
add("PUBLIC_BASE_URL must include a host, got %q", c.PublicBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
if c.BasePath != "" && !strings.HasPrefix(c.BasePath, "/") {
|
||||
add("BASE_PATH must start with a slash, got %q", c.BasePath)
|
||||
}
|
||||
|
||||
switch strings.ToLower(c.SessionSameSite) {
|
||||
case "lax", "strict", "none":
|
||||
default:
|
||||
add("SESSION_SAMESITE %q must be lax, strict or none", c.SessionSameSite)
|
||||
}
|
||||
|
||||
if strings.EqualFold(c.SessionSameSite, "none") && !c.SessionSecure {
|
||||
// Browsers reject SameSite=None without Secure, so this combination
|
||||
// silently produces a session that never survives a redirect.
|
||||
add("SESSION_SAMESITE=none requires SESSION_SECURE=true")
|
||||
}
|
||||
|
||||
if !c.SessionSecure && !c.IsDevelopment() && c.PublicBaseURL != "" {
|
||||
if strings.HasPrefix(c.PublicBaseURL, "https://") {
|
||||
add("SESSION_SECURE must be true when PUBLIC_BASE_URL is https")
|
||||
}
|
||||
}
|
||||
|
||||
if c.DatabasePath == "" {
|
||||
add("DATABASE_PATH must not be empty")
|
||||
}
|
||||
if c.DatabaseMaxConns < 1 {
|
||||
add("DB_MAX_CONNS must be at least 1, got %d", c.DatabaseMaxConns)
|
||||
}
|
||||
if c.RateLimitPerMinute < 0 {
|
||||
add("RATE_LIMIT_PER_MINUTE must not be negative")
|
||||
}
|
||||
if !c.IsDevelopment() && c.AuthMode == AuthModeDev {
|
||||
add("AUTH_MODE=dev is refused outside development; it grants admin to anyone who can reach the service")
|
||||
}
|
||||
if c.AuthMode == AuthModeNone && !c.IsDevelopment() {
|
||||
add("AUTH_MODE=none is refused outside development")
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDevelopment reports whether the service is running in development.
|
||||
func (c Config) IsDevelopment() bool {
|
||||
switch strings.ToLower(c.Environment) {
|
||||
case "development", "dev", "local", "test", "testing", "ci":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RedirectURL returns the OIDC callback URL, derived from the public base when
|
||||
// not overridden.
|
||||
func (c Config) RedirectURL() string {
|
||||
if c.OIDCRedirectURL != "" {
|
||||
return c.OIDCRedirectURL
|
||||
}
|
||||
return c.origin() + c.path("/auth/callback")
|
||||
}
|
||||
|
||||
// PostLogoutRedirectURL returns the OIDC end-session target.
|
||||
func (c Config) PostLogoutRedirectURL() string {
|
||||
if c.OIDCPostLogoutRedirectURL != "" {
|
||||
return c.OIDCPostLogoutRedirectURL
|
||||
}
|
||||
return c.origin() + c.path("/")
|
||||
}
|
||||
|
||||
// Origin returns the public origin with no trailing slash.
|
||||
func (c Config) Origin() string { return c.origin() }
|
||||
|
||||
// origin normalises the public base URL.
|
||||
func (c Config) origin() string {
|
||||
return strings.TrimRight(c.PublicBaseURL, "/")
|
||||
}
|
||||
|
||||
// path prefixes a route with the configured base path.
|
||||
func (c Config) path(route string) string {
|
||||
base := strings.TrimRight(c.BasePath, "/")
|
||||
if base == "" {
|
||||
return route
|
||||
}
|
||||
return base + route
|
||||
}
|
||||
|
||||
// Path exposes base-path prefixing to the HTTP layer.
|
||||
func (c Config) Path(route string) string { return c.path(route) }
|
||||
|
||||
// CDNBasis returns the absolute base that runtime asset URLs are built from,
|
||||
// honouring the CDN override.
|
||||
func (c Config) CDNBasis() string {
|
||||
if c.RuntimeCDNBaseURL != "" {
|
||||
return strings.TrimRight(c.RuntimeCDNBaseURL, "/")
|
||||
}
|
||||
return c.origin() + c.path("/rt/v1")
|
||||
}
|
||||
|
||||
// splitList parses a comma or whitespace separated environment list.
|
||||
func splitList(raw string) []string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\t' || r == ' '
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if trimmed := strings.TrimSpace(f); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseDevUsers parses the development role map in "email=role,email=role" form.
|
||||
func parseDevUsers(raw string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, pair := range splitList(raw) {
|
||||
email, role, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if email == "" || role == "" {
|
||||
continue
|
||||
}
|
||||
out[email] = role
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty value from a default and a list of
|
||||
// candidate environment lookups.
|
||||
func firstNonEmpty(def string, lookups ...func() (string, bool)) string {
|
||||
for _, lookup := range lookups {
|
||||
if v, ok := lookup(); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// isLocalHost reports whether host refers to the local machine.
|
||||
func isLocalHost(host string) bool {
|
||||
switch strings.ToLower(host) {
|
||||
case "localhost", "127.0.0.1", "::1", "keycloak", "idp", "auth":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".localhost")
|
||||
}
|
||||
}
|
||||
|
||||
// Redacted returns a copy of the configuration with secrets masked, suitable
|
||||
// for logging or for the diagnostics endpoint.
|
||||
func (c Config) Redacted() Config {
|
||||
mask := func(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return "***redacted***"
|
||||
}
|
||||
c.OIDCClientSecret = mask(c.OIDCClientSecret)
|
||||
c.SessionSecret = mask(c.SessionSecret)
|
||||
return c
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue