Files
jbergner c212155f61
All checks were successful
release-tag / release-image (push) Successful in 1m52s
Neues Branding und Text-Konzept
2026-07-26 20:59:38 +02:00

290 lines
9.2 KiB
Go

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")
}