package app import ( "errors" "fmt" "net/mail" "net/netip" "net/url" "os" "strconv" "strings" "time" "github.com/b1tsblog/license-platform/pkg/licensekit" ) type Config struct { EnvironmentErrors []string ListenAddress string BaseURL string PublicName string ContactURL string SalesURL string DefaultLanguage string TrustProxy bool TrustedProxies []netip.Prefix LogClientIP bool EnableHSTS 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 CommunityPrice string ProPrice string PublisherPrice string AgencyPrice string } func ConfigFromEnv() Config { var environmentErrors []string contactURL := env("CONTACT_URL", "https://ai.trustednet.eu") mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline")) if err != nil { environmentErrors = append(environmentErrors, "LICENSE_MODE is invalid") mode = licensekit.ModeOffline } trustProxy := boolEnv("TRUST_PROXY", false, &environmentErrors) logClientIP := boolEnv("LOG_CLIENT_IP", false, &environmentErrors) enableHSTS := boolEnv("ENABLE_HSTS", true, &environmentErrors) metricsEnabled := boolEnv("METRICS_ENABLED", false, &environmentErrors) legalStrict := boolEnv("LEGAL_STRICT", false, &environmentErrors) trustedProxies := prefixListEnv("TRUSTED_PROXY_CIDRS", &environmentErrors) return Config{ EnvironmentErrors: environmentErrors, ListenAddress: env("LISTEN_ADDRESS", ":8080"), BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"), PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"), ContactURL: contactURL, SalesURL: env("SALES_URL", contactURL), DefaultLanguage: env("DEFAULT_LANGUAGE", "de"), TrustProxy: trustProxy, TrustedProxies: trustedProxies, LogClientIP: logClientIP, EnableHSTS: enableHSTS, MetricsEnabled: metricsEnabled, MetricsToken: secretEnv("METRICS_TOKEN"), LegalStrict: legalStrict, LegalName: env("LEGAL_NAME", ""), LegalAddress: env("LEGAL_ADDRESS", ""), LegalRepresentative: env("LEGAL_REPRESENTATIVE", ""), LegalEmail: env("LEGAL_EMAIL", ""), LegalPhone: env("LEGAL_PHONE", ""), LegalRegister: env("LEGAL_REGISTER", ""), LegalRegisterNumber: env("LEGAL_REGISTER_NUMBER", ""), LegalVATID: env("LEGAL_VAT_ID", ""), EditorialResponsibleName: env("EDITORIAL_RESPONSIBLE_NAME", ""), EditorialResponsibleAddress: env("EDITORIAL_RESPONSIBLE_ADDRESS", ""), DataProtectionContact: env("DATA_PROTECTION_CONTACT", ""), HostingProvider: env("HOSTING_PROVIDER", ""), HostingAddress: env("HOSTING_ADDRESS", ""), LogRetention: env("LOG_RETENTION", ""), DataRecipients: env("DATA_RECIPIENTS", ""), ThirdCountryTransfers: env("THIRD_COUNTRY_TRANSFERS", ""), SupervisoryAuthorityName: env("SUPERVISORY_AUTHORITY_NAME", ""), SupervisoryAuthorityURL: env("SUPERVISORY_AUTHORITY_URL", ""), ConsumerDisputeStatus: strings.ToLower(env("CONSUMER_DISPUTE_STATUS", "")), ConsumerDisputeBody: env("CONSUMER_DISPUTE_BODY", ""), ConsumerDisputeURL: env("CONSUMER_DISPUTE_URL", ""), AccessibilityContact: env("ACCESSIBILITY_CONTACT", ""), AccessibilityStatus: env("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), CommunityPrice: env("PRICE_COMMUNITY", "0 €"), ProPrice: env("PRICE_PRO", "19 €"), PublisherPrice: env("PRICE_PUBLISHER", "79 €"), AgencyPrice: env("PRICE_AGENCY", "199 €"), } } func validateConfig(cfg Config) error { if len(cfg.EnvironmentErrors) > 0 { return fmt.Errorf("invalid environment configuration: %s", strings.Join(cfg.EnvironmentErrors, "; ")) } u, err := url.Parse(cfg.BaseURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { return fmt.Errorf("BASE_URL must be an absolute http(s) origin without path, query, credentials or fragment") } if strings.TrimSpace(cfg.PublicName) == "" { return errors.New("PUBLIC_NAME must not be empty") } if cfg.TrustProxy && len(cfg.TrustedProxies) == 0 { return errors.New("TRUST_PROXY=true requires at least one TRUSTED_PROXY_CIDRS entry") } if cfg.MetricsEnabled && configValueMissing(cfg.MetricsToken) { return errors.New("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE") } for _, item := range []struct { name, value string }{ {"CONTACT_URL", cfg.ContactURL}, {"SALES_URL", cfg.SalesURL}, {"SUPERVISORY_AUTHORITY_URL", cfg.SupervisoryAuthorityURL}, {"CONSUMER_DISPUTE_URL", cfg.ConsumerDisputeURL}, } { if err := validateOptionalHTTPURL(item.name, item.value); err != nil { return err } } for _, item := range []struct { name, value string }{ {"LEGAL_EMAIL", cfg.LegalEmail}, {"DATA_PROTECTION_CONTACT", cfg.DataProtectionContact}, {"ACCESSIBILITY_CONTACT", cfg.AccessibilityContact}, } { if err := validateOptionalEmail(item.name, item.value); err != nil { return err } } if cfg.LegalStrict { missing := legalRequiredFields(cfg) if len(missing) > 0 { return fmt.Errorf("LEGAL_STRICT=true but required legal configuration is missing: %s", strings.Join(missing, ", ")) } } switch cfg.ConsumerDisputeStatus { case "", "not_applicable", "not_participating", "participating": default: return errors.New("CONSUMER_DISPUTE_STATUS must be empty, not_applicable, not_participating or participating") } if cfg.ConsumerDisputeStatus == "participating" && (cfg.ConsumerDisputeBody == "" || cfg.ConsumerDisputeURL == "") { return errors.New("CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL are required when CONSUMER_DISPUTE_STATUS=participating") } return nil } func legalRequiredFields(cfg Config) []string { var missing []string for _, field := range []struct{ key, value string }{ {"LEGAL_NAME", cfg.LegalName}, {"LEGAL_ADDRESS", cfg.LegalAddress}, {"LEGAL_EMAIL", cfg.LegalEmail}, } { if configValueMissing(field.value) { missing = append(missing, field.key) } } if configValueMissing(cfg.LogRetention) { missing = append(missing, "LOG_RETENTION") } if configValueMissing(cfg.HostingProvider) { missing = append(missing, "HOSTING_PROVIDER") } if configValueMissing(cfg.ConsumerDisputeStatus) { missing = append(missing, "CONSUMER_DISPUTE_STATUS") } return missing } func configValueMissing(value string) bool { value = strings.ToUpper(strings.TrimSpace(value)) return value == "" || value == "REPLACE_ME" || value == "CHANGEME" || value == "TODO" } func validateOptionalHTTPURL(name, value string) error { value = strings.TrimSpace(value) if value == "" { return nil } u, err := url.Parse(value) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { return fmt.Errorf("%s must be an absolute http(s) URL without credentials", name) } return nil } func validateOptionalEmail(name, value string) error { value = strings.TrimSpace(value) if value == "" || configValueMissing(value) { return nil } address, err := mail.ParseAddress(value) if err != nil || address.Address != value { return fmt.Errorf("%s must be a plain valid email address", name) } return nil } func env(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func boolEnv(key string, fallback bool, environmentErrors *[]string) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { *environmentErrors = append(*environmentErrors, key+" must be true or false") return fallback } return parsed } func secretEnv(key string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } path := strings.TrimSpace(os.Getenv(key + "_FILE")) if path == "" { return "" } data, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(data)) } func prefixListEnv(key string, environmentErrors *[]string) []netip.Prefix { var out []netip.Prefix for _, raw := range strings.Split(os.Getenv(key), ",") { raw = strings.TrimSpace(raw) if raw == "" { continue } prefix, err := netip.ParsePrefix(raw) if err != nil { *environmentErrors = append(*environmentErrors, key+" contains invalid CIDR "+raw) continue } out = append(out, prefix.Masked()) } return out } 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 }