// backend\settings.go package main import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "github.com/sqweek/dialog" ) type RecorderSettings struct { DatabaseURL string `json:"databaseUrl"` EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext) RecordDir string `json:"recordDir"` DoneDir string `json:"doneDir"` FFmpegPath string `json:"ffmpegPath"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` // Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind. AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` // still | hover | all TeaserAudio bool `json:"teaserAudio"` // ✅ Vorschau/Teaser mit Ton abspielen EnableNotifications bool `json:"enableNotifications"` // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. EncryptedCookies string `json:"encryptedCookies"` } var ( settingsMu sync.Mutex settings = RecorderSettings{ DatabaseURL: "", EncryptedDBPassword: "", RecordDir: "/records", DoneDir: "/records/done", FFmpegPath: "", AutoAddToDownloadList: false, AutoStartAddedDownloads: false, UseChaturbateAPI: false, UseMyFreeCamsWatcher: false, AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloadsBelowMB: 50, BlurPreviews: false, TeaserPlayback: "hover", TeaserAudio: false, EnableNotifications: true, EncryptedCookies: "", } settingsFile = "recorder_settings.json" ) func settingsFilePath() string { // optionaler Override per ENV name := settingsFile // Standard: relativ zur EXE / App-Dir (oder fallback auf Working Dir bei go run) if p, err := resolvePathRelativeToApp(name); err == nil && strings.TrimSpace(p) != "" { return p } // Fallback: so zurückgeben wie es ist return name } func getSettings() RecorderSettings { settingsMu.Lock() defer settingsMu.Unlock() return settings } func loadSettings() { p := settingsFilePath() b, err := os.ReadFile(p) fmt.Println("🔧 settingsFile:", p) if err == nil { s := getSettings() // ✅ startet mit Defaults if json.Unmarshal(b, &s) == nil { if strings.TrimSpace(s.RecordDir) != "" { s.RecordDir = filepath.Clean(strings.TrimSpace(s.RecordDir)) } if strings.TrimSpace(s.DoneDir) != "" { s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir)) } if strings.TrimSpace(s.FFmpegPath) != "" { s.FFmpegPath = strings.TrimSpace(s.FFmpegPath) } s.TeaserPlayback = strings.ToLower(strings.TrimSpace(s.TeaserPlayback)) if s.TeaserPlayback == "" { s.TeaserPlayback = "hover" } if s.TeaserPlayback != "still" && s.TeaserPlayback != "hover" && s.TeaserPlayback != "all" { s.TeaserPlayback = "hover" } // Auto-Delete: clamp if s.AutoDeleteSmallDownloadsBelowMB < 0 { s.AutoDeleteSmallDownloadsBelowMB = 0 } if s.AutoDeleteSmallDownloadsBelowMB > 100_000 { s.AutoDeleteSmallDownloadsBelowMB = 100_000 } settingsMu.Lock() settings = s settingsMu.Unlock() } s.DatabaseURL = strings.TrimSpace(s.DatabaseURL) // Optional: falls in der JSON mal ein URL MIT Passwort steht (Altbestand) // -> Passwort extrahieren und verschlüsselt ablegen (nur wenn noch keins gesetzt ist) if s.DatabaseURL != "" && strings.TrimSpace(s.EncryptedDBPassword) == "" { sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(s.DatabaseURL) if sanitizedURL != "" { s.DatabaseURL = sanitizedURL } if strings.TrimSpace(pwFromURL) != "" { if enc, err := encryptSettingString(strings.TrimSpace(pwFromURL)); err == nil { s.EncryptedDBPassword = enc } } } } // Ordner sicherstellen s := getSettings() recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) if strings.TrimSpace(recordAbs) != "" { _ = os.MkdirAll(recordAbs, 0o755) } if strings.TrimSpace(doneAbs) != "" { _ = os.MkdirAll(doneAbs, 0o755) } // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen ffmpegPath = detectFFmpegPath() fmt.Println("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() fmt.Println("🔍 ffprobePath:", ffprobePath) } func saveSettingsToDisk() { s := getSettings() b, err := json.MarshalIndent(s, "", " ") if err != nil { fmt.Println("⚠️ settings marshal:", err) return } b = append(b, '\n') p := settingsFilePath() if err := atomicWriteFile(p, b); err != nil { fmt.Println("⚠️ settings write:", err) return } // optional // fmt.Println("✅ settings saved:", p) } type RecorderSettingsPublic struct { RecordDir string `json:"recordDir"` DoneDir string `json:"doneDir"` FFmpegPath string `json:"ffmpegPath"` DatabaseURL string `json:"databaseUrl"` HasDBPassword bool `json:"hasDbPassword"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` TeaserAudio bool `json:"teaserAudio"` EnableNotifications bool `json:"enableNotifications"` } func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { return RecorderSettingsPublic{ RecordDir: s.RecordDir, DoneDir: s.DoneDir, FFmpegPath: s.FFmpegPath, DatabaseURL: strings.TrimSpace(s.DatabaseURL), HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "", AutoAddToDownloadList: s.AutoAddToDownloadList, AutoStartAddedDownloads: s.AutoStartAddedDownloads, UseChaturbateAPI: s.UseChaturbateAPI, UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher, AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, BlurPreviews: s.BlurPreviews, TeaserPlayback: s.TeaserPlayback, TeaserAudio: s.TeaserAudio, EnableNotifications: s.EnableNotifications, } } type RecorderSettingsIn struct { RecorderSettings DBPassword string `json:"dbPassword,omitempty"` // nur vom Frontend, NIE auf Disk speichern } func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(toPublicSettings(getSettings())) return case http.MethodPost: var in RecorderSettingsIn if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) return } // --- normalize (WICHTIG: erst trim, dann leer-check, dann clean) --- recRaw := strings.TrimSpace(in.RecordDir) doneRaw := strings.TrimSpace(in.DoneDir) if recRaw == "" || doneRaw == "" { http.Error(w, "recordDir und doneDir dürfen nicht leer sein", http.StatusBadRequest) return } in.RecordDir = filepath.Clean(recRaw) in.DoneDir = filepath.Clean(doneRaw) // Optional aber sehr empfehlenswert: "." verbieten if in.RecordDir == "." || in.DoneDir == "." { http.Error(w, "recordDir/doneDir dürfen nicht '.' sein", http.StatusBadRequest) return } in.FFmpegPath = strings.TrimSpace(in.FFmpegPath) in.TeaserPlayback = strings.ToLower(strings.TrimSpace(in.TeaserPlayback)) if in.TeaserPlayback == "" { in.TeaserPlayback = "hover" } if in.TeaserPlayback != "still" && in.TeaserPlayback != "hover" && in.TeaserPlayback != "all" { in.TeaserPlayback = "hover" } // Auto-Delete: clamp if in.AutoDeleteSmallDownloadsBelowMB < 0 { in.AutoDeleteSmallDownloadsBelowMB = 0 } if in.AutoDeleteSmallDownloadsBelowMB > 100_000 { in.AutoDeleteSmallDownloadsBelowMB = 100_000 } // --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) --- recAbs, err := resolvePathRelativeToApp(in.RecordDir) if err != nil { http.Error(w, "ungültiger recordDir: "+err.Error(), http.StatusBadRequest) return } doneAbs, err := resolvePathRelativeToApp(in.DoneDir) if err != nil { http.Error(w, "ungültiger doneDir: "+err.Error(), http.StatusBadRequest) return } if err := os.MkdirAll(recAbs, 0o755); err != nil { http.Error(w, "konnte recordDir nicht erstellen: "+err.Error(), http.StatusBadRequest) return } if err := os.MkdirAll(doneAbs, 0o755); err != nil { http.Error(w, "konnte doneDir nicht erstellen: "+err.Error(), http.StatusBadRequest) return } // --- DB URL + Passwort behandeln --- // 1) Trim in.DatabaseURL = strings.TrimSpace(in.DatabaseURL) // 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren // und URL ohne Passwort zurückschreiben. sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(in.DatabaseURL) if sanitizedURL != "" { in.DatabaseURL = sanitizedURL } // 3) Wenn Frontend ein Passwort sendet, hat das Priorität. plainPW := strings.TrimSpace(in.DBPassword) if plainPW == "" { plainPW = pwFromURL } // 4) Wenn wir ein neues Passwort haben: encrypten & speichern (nur encrypted!) if plainPW != "" { enc, err := encryptSettingString(plainPW) if err != nil { http.Error(w, "konnte DB-Passwort nicht verschlüsseln: "+err.Error(), http.StatusInternalServerError) return } in.EncryptedDBPassword = enc } // ✅ Settings im RAM aktualisieren settingsMu.Lock() settings = in.RecorderSettings settingsMu.Unlock() // ✅ Settings auf Disk persistieren saveSettingsToDisk() // ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen // Tipp: wenn der User FFmpegPath explizit setzt, nutze den direkt. if strings.TrimSpace(in.FFmpegPath) != "" { ffmpegPath = in.FFmpegPath } else { ffmpegPath = detectFFmpegPath() } fmt.Println("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() fmt.Println("🔍 ffprobePath:", ffprobePath) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(toPublicSettings(getSettings())) return default: http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) return } } func settingsBrowse(w http.ResponseWriter, r *http.Request) { target := r.URL.Query().Get("target") if target != "record" && target != "done" && target != "ffmpeg" { http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest) return } var ( p string err error ) if target == "ffmpeg" { // Dateiauswahl für ffmpeg.exe p, err = dialog.File(). Title("ffmpeg.exe auswählen"). Load() } else { // Ordnerauswahl für record/done p, err = dialog.Directory(). Title("Ordner auswählen"). Browse() } if err != nil { // User cancelled → 204 No Content ist praktisch fürs Frontend if strings.Contains(strings.ToLower(err.Error()), "cancel") { w.WriteHeader(http.StatusNoContent) return } http.Error(w, "auswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // optional: wenn innerhalb exe-dir, als RELATIV zurückgeben p = maybeMakeRelativeToExe(p) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"path": p}) } func maybeMakeRelativeToExe(abs string) string { exe, err := os.Executable() if err != nil { return abs } base := filepath.Dir(exe) rel, err := filepath.Rel(base, abs) if err != nil { return abs } // wenn rel mit ".." beginnt -> nicht innerhalb base -> absoluten Pfad behalten if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { return abs } return filepath.ToSlash(rel) // frontend-freundlich }