314 lines
10 KiB
Go
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
|
|
}
|