"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL are configured. The request always gets the same answer and sends the mail in the background (at most every 2 minutes per account). The link holds a random token valid once for one hour; only its SHA-256 is stored. Setting the new password invalidates all open links and ends every session. The app removes the token from the address bar and offers a way back for expired links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
6.4 KiB
Go
200 lines
6.4 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
|
||
// 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
|
||
}
|