// Package config loads and validates all application configuration from the // environment (optionally seeded from a .env file). The app fails fast at // startup on invalid configuration rather than misbehaving later. package config import ( "encoding/base64" "errors" "fmt" "net/netip" "net/url" "os" "strconv" "strings" "time" ) type SMTP struct { Host string Port int User string Password string From string } func (s SMTP) Enabled() bool { return s.Host != "" } type OIDC struct { IssuerURL string ClientID string ClientSecret string } func (o OIDC) Enabled() bool { return o.IssuerURL != "" && o.ClientID != "" && o.ClientSecret != "" } type Config struct { AppBaseURL string // AppInternalURL is how the external services reach the app from inside // the compose network (callback URLs, signed attachment URLs). Defaults // to AppBaseURL. AppInternalURL string TrustedProxyCIDRs []netip.Prefix AppPort int MongoURI string MongoDB string MongoUsername string MongoPassword string SessionSecret string CredentialsEncKey []byte // 32 bytes, decoded MaxUploadMB int AdminEmail string AdminInitialPassword string AtomizerBaseURL string AtomizerToken string AtomizerTimeout time.Duration // AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue // Atomizer as the Subdivide backend. Both must be set; Extend and // Summarize stay on ATOMIZER_BASE_URL either way. AtomizerRemoteBaseURL string AtomizerRemoteAPIKey string WorkPerformerBaseURL string WorkPerformerToken string WorkPerformerHTTPTimeout time.Duration SMTP SMTP OIDC OIDC CookieSecureMode string // auto | true | false AtomizeMaxConcurrency int } // CookieSecure resolves the Secure cookie flag: explicit true/false, or in // auto mode derived from the APP_BASE_URL scheme. func (c *Config) CookieSecure() bool { switch c.CookieSecureMode { case "true": return true case "false": return false default: return strings.HasPrefix(c.AppBaseURL, "https://") } } func getenv(key, def string) string { if v, ok := os.LookupEnv(key); ok { return v } return def } // Load reads configuration from the environment and validates it. All // problems are reported together so a broken deployment is fixed in one pass. func Load() (*Config, error) { var errs []error fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) } atoi := func(key, def string) int { raw := getenv(key, def) n, err := strconv.Atoi(raw) if err != nil { fail("%s: %q is not an integer", key, raw) } return n } c := &Config{ AppBaseURL: strings.TrimRight(getenv("APP_BASE_URL", "http://localhost:8787"), "/"), AppPort: atoi("APP_PORT", "8787"), MongoURI: getenv("MONGO_URI", "mongodb://localhost:27017"), MongoDB: getenv("MONGO_DB", "bountyboard"), MongoUsername: os.Getenv("MONGO_USERNAME"), MongoPassword: os.Getenv("MONGO_PASSWORD"), SessionSecret: os.Getenv("SESSION_SECRET"), MaxUploadMB: atoi("MAX_UPLOAD_MB", "25"), AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))), AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"), AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"), AtomizerToken: os.Getenv("ATOMIZER_TOKEN"), AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"), AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"), WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"), WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"), SMTP: SMTP{ Host: os.Getenv("SMTP_HOST"), Port: atoi("SMTP_PORT", "587"), User: os.Getenv("SMTP_USER"), Password: os.Getenv("SMTP_PASSWORD"), From: getenv("SMTP_FROM", "bountyboard@example.com"), }, OIDC: OIDC{ IssuerURL: os.Getenv("OIDC_ISSUER_URL"), ClientID: os.Getenv("OIDC_CLIENT_ID"), ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), }, CookieSecureMode: getenv("COOKIE_SECURE", "auto"), AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"), } c.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/") c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second if c.AppPort < 1 || c.AppPort > 65535 { fail("APP_PORT: %d out of range", c.AppPort) } if u, err := url.Parse(c.AppBaseURL); err != nil || u.Scheme == "" || u.Host == "" { fail("APP_BASE_URL: %q is not an absolute URL", c.AppBaseURL) } for _, name := range []string{"ATOMIZER_BASE_URL", "WORK_PERFORMER_BASE_URL"} { v := c.AtomizerBaseURL if name == "WORK_PERFORMER_BASE_URL" { v = c.WorkPerformerBaseURL } if u, err := url.Parse(v); err != nil || u.Scheme == "" || u.Host == "" { fail("%s: %q is not an absolute URL", name, v) } } if c.AtomizerRemoteBaseURL != "" { if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" { fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL) } if c.AtomizerRemoteAPIKey == "" { fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set") } } if len(c.SessionSecret) < 16 { fail("SESSION_SECRET: must be at least 16 characters of random data") } switch raw := os.Getenv("CREDENTIALS_ENC_KEY"); raw { case "": fail("CREDENTIALS_ENC_KEY: required; generate with `openssl rand -base64 32`") default: key, err := base64.StdEncoding.DecodeString(raw) if err != nil || len(key) != 32 { fail("CREDENTIALS_ENC_KEY: must be base64 of exactly 32 bytes") } c.CredentialsEncKey = key } if c.MaxUploadMB < 1 { fail("MAX_UPLOAD_MB: must be >= 1") } if c.AtomizeMaxConcurrency < 1 { fail("ATOMIZE_MAX_CONCURRENCY: must be >= 1") } switch c.CookieSecureMode { case "auto", "true", "false": default: fail("COOKIE_SECURE: %q must be auto, true or false", c.CookieSecureMode) } if raw := strings.TrimSpace(os.Getenv("TRUSTED_PROXY_CIDRS")); raw != "" { for cidr := range strings.SplitSeq(raw, ",") { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } p, err := netip.ParsePrefix(cidr) if err != nil { fail("TRUSTED_PROXY_CIDRS: %q is not a CIDR", cidr) continue } c.TrustedProxyCIDRs = append(c.TrustedProxyCIDRs, p.Masked()) } } if len(errs) > 0 { return nil, fmt.Errorf("invalid configuration:\n%w", errors.Join(errs...)) } return c, nil }