package config import ( "crypto/rand" "encoding/hex" "log/slog" "os" "path/filepath" "strings" ) type Config struct { Port string DataDir string DatabasePath string JWTSecret string BaseURL string // BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built // from it, never from the request's Host header. BaseURLSet bool AllowRegistration bool LogLevel string Debug bool DemoMode bool // Optional SMTP environment variables (can also be overridden/configured in DB system_settings) SMTPHost string SMTPPort string SMTPUser string SMTPPass string SMTPFrom string SMTPTLS bool // SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers) SMTPSkipVerify bool // Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login Legal LegalTexts } type LegalTexts struct { ImprintURL, ImprintContent string PrivacyURL, PrivacyContent string } // Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and // README). Signing with one of them would let anyone forge login tokens. var insecureJWTSecrets = map[string]bool{ "mindsift-default-selfhosted-jwt-secret-key-2026": true, "change_this_to_a_secure_random_key_min_32_chars": true, "hier-ein-langer-zufaelliger-wert": true, "changeme": true, "secret": true, } const jwtSecretFile = "jwt.secret" // resolveJWTSecret uses JWT_SECRET when it is set to a real value. Otherwise it generates a random // secret once and keeps it in the data directory, so logins survive restarts without anybody being // able to forge tokens with a known default. func resolveJWTSecret(fromEnv, dataDir string) string { if v := strings.TrimSpace(fromEnv); v != "" && !insecureJWTSecrets[v] { if len(v) < 32 { slog.Warn("JWT_SECRET ist kürzer als 32 Zeichen – bitte einen längeren Zufallswert verwenden") } return v } else if v != "" { slog.Warn("JWT_SECRET ist ein öffentlich bekannter Platzhalter und wird ignoriert") } path := filepath.Join(dataDir, jwtSecretFile) if data, err := os.ReadFile(path); err == nil { if s := strings.TrimSpace(string(data)); len(s) >= 32 { return s } } buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { panic("crypto/rand: " + err.Error()) } secret := hex.EncodeToString(buf) _ = os.MkdirAll(dataDir, 0o755) if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil { slog.Error("Erzeugtes JWT-Secret konnte nicht gespeichert werden – Anmeldungen gelten nur bis zum Neustart", "pfad", path, "error", err) } else { slog.Warn("Kein JWT_SECRET gesetzt: zufälliges Secret erzeugt und im Datenverzeichnis gespeichert. Für Produktion besser JWT_SECRET setzen.", "pfad", path) } return secret } // legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE. func legalContent(name string) string { if v := strings.TrimSpace(os.Getenv(name)); v != "" { return v } if path := strings.TrimSpace(os.Getenv(name + "_FILE")); path != "" { data, err := os.ReadFile(path) if err != nil { slog.Warn("Rechtstext konnte nicht gelesen werden", "variable", name+"_FILE", "error", err) return "" } return strings.TrimSpace(string(data)) } return "" } // legalURL accepts only http(s) links, so a misconfiguration cannot inject javascript: URLs. func legalURL(name string) string { v := strings.TrimSpace(os.Getenv(name)) if v == "" { return "" } if !strings.HasPrefix(v, "https://") && !strings.HasPrefix(v, "http://") { slog.Warn("Rechtstext-URL ignoriert: nur http(s) erlaubt", "variable", name) return "" } return v } func Load() *Config { port := getEnv("PORT", "8080") dataDir := getEnv("DATA_DIR", "./data") dbURL := getEnv("DATABASE_URL", "") dbPath := filepath.Join(dataDir, "mindsift.db") if strings.HasPrefix(dbURL, "sqlite://") { dbPath = strings.TrimPrefix(dbURL, "sqlite://") } else if dbURL != "" && !strings.HasPrefix(dbURL, "postgres") { dbPath = dbURL } jwtSecret := resolveJWTSecret(os.Getenv("JWT_SECRET"), filepath.Dir(dbPath)) baseURL := getEnv("BASE_URL", "http://localhost:"+port) allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true" debugVal := strings.ToLower(getEnv("DEBUG", "false")) debug := debugVal == "true" || debugVal == "1" || debugVal == "yes" || debugVal == "on" logLevel := strings.ToLower(getEnv("LOG_LEVEL", "info")) if debug { logLevel = "debug" } demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false")))) demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on" smtpHost := getEnv("SMTP_HOST", "") smtpPort := getEnv("SMTP_PORT", "587") smtpUser := getEnv("SMTP_USER", "") smtpPass := getEnv("SMTP_PASS", "") smtpFrom := getEnv("SMTP_FROM", "") smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false" smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true" return &Config{ Port: port, DataDir: dataDir, DatabasePath: dbPath, JWTSecret: jwtSecret, BaseURL: baseURL, BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", AllowRegistration: allowReg, LogLevel: logLevel, Debug: debug, DemoMode: demoMode, SMTPHost: smtpHost, SMTPPort: smtpPort, SMTPUser: smtpUser, SMTPPass: smtpPass, SMTPFrom: smtpFrom, SMTPTLS: smtpTLS, SMTPSkipVerify: smtpSkipVerify, Legal: LegalTexts{ ImprintURL: legalURL("LEGAL_IMPRINT_URL"), ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), PrivacyURL: legalURL("LEGAL_PRIVACY_URL"), PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"), }, } } // SlogLevel parses Config.LogLevel into a standard slog.Level. func (c *Config) SlogLevel() slog.Level { switch strings.ToLower(strings.TrimSpace(c.LogLevel)) { case "debug": return slog.LevelDebug case "warn", "warning": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } } func getEnv(key, fallback string) string { if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { return strings.TrimSpace(val) } return fallback }