Files
ai-disclosure-standard/internal/app/config.go
T
jbergner 0e6f71e1e6
release-tag / release-image (push) Successful in 1m39s
Impressum, Datenschutzerklärung und Barrierefreiheitshinweise
2026-07-21 03:29:41 +02:00

314 lines
10 KiB
Go

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
}