Files
MindSift/internal/config/config.go
T
sandrodachandClaude Opus 5.5 56c1fa02e6
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m54s
feat(mail): sender display name (SMTP_FROM_NAME, default MindSift)
A bare SMTP_FROM address gets the display name in the From header; a name
already in SMTP_FROM wins, the envelope keeps the bare address. Also a
field in the admin mail settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:47:38 +01:00

297 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"strconv"
"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
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
DemoResetMinutes int
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
TrustedProxies string
// DisableLoginProtection: LOGIN_PROTECTION=false switches off locking logins after repeated
// failures (the zero value keeps it on)
DisableLoginProtection bool
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
SMTPFrom string
// SMTPFromName is the sender's display name when SMTP_FROM is a bare address
SMTPFromName 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
// Speech recognition, configured only by environment (STT_*)
Speech SpeechConfig
// Images and audio in notes (MEDIA_*)
Media MediaConfig
}
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
// per user. The demo uses DemoMaxMB for every file.
type MediaConfig struct {
MaxImageMB int
MaxAudioMB int
QuotaMB int
DemoMaxMB int
}
// SpeechConfig: with STT_URL set, voice input is transcribed by a self-hosted, OpenAI-compatible
// Whisper server (e.g. speaches); otherwise the browser's own speech recognition is used.
type SpeechConfig struct {
URL string // base URL, e.g. http://whisper:8000
Model string
Language string
APIKey string
Disabled bool // STT_DISABLED=true hides voice input everywhere
MaxSeconds int // recordings stop after this long (enforced in the browser)
MaxUploadMB int
MaxParallel int // transcriptions running at the same time; more wait in line
PerUserPerHour int
TimeoutSeconds int
}
// Mode is "server", "browser" or "off". The demo never uses the server, so visitors cannot keep
// its CPU busy.
func (s SpeechConfig) Mode(demo bool) string {
switch {
case s.Disabled:
return "off"
case s.URL != "" && !demo:
return "server"
default:
return "browser"
}
}
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,
DemoResetMinutes: demoResetMinutes(),
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
Speech: SpeechConfig{
URL: strings.TrimRight(getEnv("STT_URL", ""), "/"),
Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"),
Language: getEnv("STT_LANGUAGE", "de"),
APIKey: getEnv("STT_API_KEY", ""),
Disabled: strings.ToLower(getEnv("STT_DISABLED", "false")) == "true",
MaxSeconds: envInt("STT_MAX_SECONDS", 600, 5, 3600),
MaxUploadMB: envInt("STT_MAX_UPLOAD_MB", 25, 1, 500),
MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32),
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
},
Media: MediaConfig{
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000),
DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100),
},
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
}
// envInt reads a whole number within [min, max]; anything else falls back to def (with a warning).
func envInt(key string, def, min, max int) int {
raw := getEnv(key, "")
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n < min || n > max {
slog.Warn("Ungültiger Wert, Standard wird verwendet", "variable", key, "value", raw, "default", def)
return def
}
return n
}
func demoResetMinutes() int {
n, err := strconv.Atoi(strings.TrimSpace(getEnv("DEMO_RESET_MINUTES", "20")))
if err != nil || n < 0 {
return 20
}
return n
}