All checks were successful
release-tag / release-image (push) Successful in 1m52s
290 lines
9.2 KiB
Go
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")
|
|
}
|