Without JWT_SECRET, or with one of the published placeholders, the server generates 32 random bytes once and keeps them in the data directory (jwt.secret, mode 0600), so tokens cannot be forged and logins survive restarts. Instances that ran on the old default log in once more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
6.2 KiB
Go
196 lines
6.2 KiB
Go
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
|
||
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,
|
||
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
|
||
}
|