package app import ( "fmt" "net/netip" "os" "strconv" "strings" "time" "github.com/b1tsblog/license-platform/pkg/licensekit" ) type Config struct { ListenAddress string BaseURL string OutputBaseURL string PublicName string ContactURL string DefaultLanguage string TrustProxy bool TrustedProxies []netip.Prefix LogClientIP bool EnableHSTS bool ServiceMode string BulkMaxItems int BulkMaxBodyBytes int64 BulkRequireAPIKey bool BulkAPIKey string APIAllowedOrigin string RequireLicense bool WhiteLabel bool MetricsEnabled bool MetricsToken string LegalStrict bool LegalName string LegalAddress string LegalRepresentative string LegalEmail string LegalPhone string LegalRegister string LegalRegisterNumber string LegalVATID string EditorialResponsibleName string EditorialResponsibleAddress string DataProtectionContact string HostingProvider string HostingAddress string LogRetention string DataRecipients string ThirdCountryTransfers string SupervisoryAuthorityName string SupervisoryAuthorityURL string ConsumerDisputeStatus string ConsumerDisputeBody string ConsumerDisputeURL string AccessibilityContact string AccessibilityStatus string LicenseToken string LicenseMode licensekit.VerificationMode LicenseServerURL string LicenseInstanceID string LicenseCacheFile string LicenseRefreshEvery time.Duration LicenseTimeout time.Duration } func ConfigFromEnv() Config { contactURL := env("CONTACT_URL", "https://b1tsblog.org/page/ai") baseURL := strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/") mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline")) if err != nil { mode = licensekit.ModeOffline } return Config{ ListenAddress: env("LISTEN_ADDRESS", ":8080"), BaseURL: baseURL, OutputBaseURL: strings.TrimRight(env("OUTPUT_BASE_URL", baseURL), "/"), PublicName: env("PUBLIC_NAME", "UCNG"), ContactURL: contactURL, DefaultLanguage: env("DEFAULT_LANGUAGE", "de"), TrustProxy: boolEnv("TRUST_PROXY", false), TrustedProxies: cidrEnv("TRUSTED_PROXY_CIDRS"), LogClientIP: boolEnv("LOG_CLIENT_IP", false), EnableHSTS: boolEnv("ENABLE_HSTS", false), ServiceMode: serviceModeEnv("SERVICE_MODE", "full"), BulkMaxItems: intEnv("BULK_MAX_ITEMS", 500), BulkMaxBodyBytes: int64(intEnv("BULK_MAX_BODY_BYTES", 4<<20)), BulkRequireAPIKey: boolEnv("BULK_REQUIRE_API_KEY", false), BulkAPIKey: secretEnv("BULK_API_KEY"), APIAllowedOrigin: envAllowEmpty("API_ALLOWED_ORIGIN", "*"), RequireLicense: boolEnv("REQUIRE_LICENSE", false), WhiteLabel: boolEnv("WHITE_LABEL", false), MetricsEnabled: boolEnv("METRICS_ENABLED", false), MetricsToken: secretEnv("METRICS_TOKEN"), LegalStrict: boolEnv("LEGAL_STRICT", false), LegalName: envAllowEmpty("LEGAL_NAME", ""), LegalAddress: envAllowEmpty("LEGAL_ADDRESS", ""), LegalRepresentative: envAllowEmpty("LEGAL_REPRESENTATIVE", ""), LegalEmail: envAllowEmpty("LEGAL_EMAIL", ""), LegalPhone: envAllowEmpty("LEGAL_PHONE", ""), LegalRegister: envAllowEmpty("LEGAL_REGISTER", ""), LegalRegisterNumber: envAllowEmpty("LEGAL_REGISTER_NUMBER", ""), LegalVATID: envAllowEmpty("LEGAL_VAT_ID", ""), EditorialResponsibleName: envAllowEmpty("EDITORIAL_RESPONSIBLE_NAME", ""), EditorialResponsibleAddress: envAllowEmpty("EDITORIAL_RESPONSIBLE_ADDRESS", ""), DataProtectionContact: envAllowEmpty("DATA_PROTECTION_CONTACT", ""), HostingProvider: envAllowEmpty("HOSTING_PROVIDER", ""), HostingAddress: envAllowEmpty("HOSTING_ADDRESS", ""), LogRetention: envAllowEmpty("LOG_RETENTION", ""), DataRecipients: envAllowEmpty("DATA_RECIPIENTS", ""), ThirdCountryTransfers: envAllowEmpty("THIRD_COUNTRY_TRANSFERS", ""), SupervisoryAuthorityName: envAllowEmpty("SUPERVISORY_AUTHORITY_NAME", ""), SupervisoryAuthorityURL: envAllowEmpty("SUPERVISORY_AUTHORITY_URL", ""), ConsumerDisputeStatus: disputeStatusEnv("CONSUMER_DISPUTE_STATUS", "not_applicable"), ConsumerDisputeBody: envAllowEmpty("CONSUMER_DISPUTE_BODY", ""), ConsumerDisputeURL: envAllowEmpty("CONSUMER_DISPUTE_URL", ""), AccessibilityContact: envAllowEmpty("ACCESSIBILITY_CONTACT", ""), AccessibilityStatus: envAllowEmpty("ACCESSIBILITY_STATUS", ""), LicenseToken: secretEnv("LICENSE_TOKEN"), LicenseMode: mode, LicenseServerURL: strings.TrimRight(env("LICENSE_SERVER_URL", ""), "/"), LicenseInstanceID: env("LICENSE_INSTANCE_ID", ""), LicenseCacheFile: env("LICENSE_CACHE_FILE", "./data/license-lease.json"), LicenseRefreshEvery: durationEnv("LICENSE_REFRESH_INTERVAL", 15*time.Minute), LicenseTimeout: durationEnv("LICENSE_REQUEST_TIMEOUT", 5*time.Second), } } func (c Config) Validate() error { if c.TrustProxy && len(c.TrustedProxies) == 0 { return fmt.Errorf("TRUST_PROXY=true requires at least one valid TRUSTED_PROXY_CIDRS entry") } if c.MetricsEnabled && len(c.MetricsToken) < 32 { return fmt.Errorf("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE with at least 32 characters") } if c.LegalStrict { required := map[string]string{ "LEGAL_NAME": c.LegalName, "LEGAL_ADDRESS": c.LegalAddress, "LEGAL_EMAIL": c.LegalEmail, "DATA_PROTECTION_CONTACT": c.DataProtectionContact, "HOSTING_PROVIDER": c.HostingProvider, "HOSTING_ADDRESS": c.HostingAddress, } missing := make([]string, 0) for key, value := range required { if placeholderOrEmpty(value) { missing = append(missing, key) } } if len(missing) > 0 { return fmt.Errorf("LEGAL_STRICT=true but required operator fields are missing or placeholders: %s", strings.Join(missing, ", ")) } if c.ConsumerDisputeStatus == "participating" && (placeholderOrEmpty(c.ConsumerDisputeBody) || placeholderOrEmpty(c.ConsumerDisputeURL)) { return fmt.Errorf("CONSUMER_DISPUTE_STATUS=participating requires CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL") } } return nil } func env(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func envAllowEmpty(key, fallback string) string { value, ok := os.LookupEnv(key) if !ok { return fallback } return strings.TrimSpace(value) } func secretEnv(key string) string { // Explicit *_FILE configuration is authoritative. This avoids a stale // environment value silently overriding Docker/Kubernetes secrets. path := strings.TrimSpace(os.Getenv(key + "_FILE")) if path != "" { data, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(data)) } return strings.TrimSpace(os.Getenv(key)) } func boolEnv(key string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func durationEnv(key string, fallback time.Duration) time.Duration { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } if duration, err := time.ParseDuration(value); err == nil { return duration } if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { return time.Duration(seconds) * time.Second } return fallback } func intEnv(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } n, err := strconv.Atoi(value) if err != nil || n <= 0 { return fallback } return n } func serviceModeEnv(key, fallback string) string { value := strings.ToLower(strings.TrimSpace(os.Getenv(key))) switch value { case "full", "api", "bulk": return value case "": return fallback default: return fallback } } func disputeStatusEnv(key, fallback string) string { value := strings.ToLower(strings.TrimSpace(os.Getenv(key))) switch value { case "not_applicable", "not_participating", "participating": return value case "": return fallback default: return fallback } } func cidrEnv(key string) []netip.Prefix { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]netip.Prefix, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } if prefix, err := netip.ParsePrefix(part); err == nil { out = append(out, prefix.Masked()) continue } if addr, err := netip.ParseAddr(part); err == nil { out = append(out, netip.PrefixFrom(addr, addr.BitLen())) } } return out } func placeholderOrEmpty(value string) bool { trimmed := strings.TrimSpace(value) if trimmed == "" { return true } upper := strings.ToUpper(trimmed) return strings.Contains(upper, "REPLACE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "TODO") }