321 lines
11 KiB
Go
321 lines
11 KiB
Go
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
|
||
|
||
// NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes
|
||
NotifyDigestMinutes int
|
||
}
|
||
|
||
// 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
|
||
CustomLinkLabel, CustomLinkURL 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
|
||
}
|
||
|
||
// legalCustomLink accepts only https links for the custom footer link.
|
||
func legalCustomLink(labelVar, urlVar string) (string, string) {
|
||
u := strings.TrimSpace(os.Getenv(urlVar))
|
||
if u == "" {
|
||
return "", ""
|
||
}
|
||
if !strings.HasPrefix(u, "https://") {
|
||
slog.Warn("Custom-Link-URL ignoriert: nur https erlaubt", "variable", urlVar, "url", u)
|
||
return "", ""
|
||
}
|
||
label := strings.TrimSpace(os.Getenv(labelVar))
|
||
return label, u
|
||
}
|
||
|
||
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),
|
||
},
|
||
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
|
||
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: func() LegalTexts {
|
||
customLabel, customURL := legalCustomLink("LEGAL_CUSTOM_LINK_LABEL", "LEGAL_CUSTOM_LINK_URL")
|
||
return LegalTexts{
|
||
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
||
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
||
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
|
||
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
|
||
CustomLinkLabel: customLabel,
|
||
CustomLinkURL: customURL,
|
||
}
|
||
}(),
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|