// backend\settings.go package main import ( "encoding/json" "net/http" "os" "path/filepath" "strings" "sync" "github.com/sqweek/dialog" ) type RecorderSettings struct { DatabaseType string `json:"databaseType"` PostgresMode string `json:"postgresMode"` // url | local PostgresDataDir string `json:"postgresDataDir"` PostgresPort int `json:"postgresPort"` PostgresDatabase string `json:"postgresDatabase"` PostgresUser string `json:"postgresUser"` 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"` EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` UseProviderAPIs bool `json:"useProviderApis"` UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"` // Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind. AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` // Wenn aktiv, werden Downloads nach erfolgreicher Analyse gelöscht, // wenn ihr Rating <= AutoDeleteLowRatedDownloadsMaxStars ist. AutoDeleteLowRatedDownloads bool `json:"autoDeleteLowRatedDownloads"` AutoDeleteLowRatedDownloadsMaxStars int `json:"autoDeleteLowRatedDownloadsMaxStars"` LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` // still | hover | all TeaserAudio bool `json:"teaserAudio"` // ✅ Vorschau/Teaser mit Ton abspielen EnableNotifications bool `json:"enableNotifications"` GenerateAssetsMeta bool `json:"generateAssetsMeta"` GenerateAssetsThumb bool `json:"generateAssetsThumb"` GenerateAssetsTeaser bool `json:"generateAssetsTeaser"` GenerateAssetsSprites bool `json:"generateAssetsSprites"` GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"` TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. EncryptedCookies string `json:"encryptedCookies"` } var ( settingsMu sync.Mutex settings = RecorderSettings{ DatabaseType: databaseTypePostgres, PostgresMode: postgresModeLocal, PostgresDataDir: defaultPostgresDataDir, PostgresPort: defaultPostgresPort, PostgresDatabase: defaultPostgresDatabase, PostgresUser: defaultPostgresUser, DatabaseURL: "", EncryptedDBPassword: "", RecordDir: defaultRecordDir, DoneDir: defaultDoneDir, FFmpegPath: "", AutoAddToDownloadList: false, AutoStartAddedDownloads: true, EnableConcurrentDownloadsLimit: false, MaxConcurrentDownloads: 20, UseProviderAPIs: false, UseChaturbateAPI: false, UseMyFreeCamsAPI: false, AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloadsBelowMB: 50, AutoDeleteSmallDownloadsKeepFavorites: true, AutoDeleteLowRatedDownloads: false, AutoDeleteLowRatedDownloadsMaxStars: 3, LowDiskPauseBelowGB: 5, BlurPreviews: false, TeaserPlayback: "hover", TeaserAudio: false, EnableNotifications: true, GenerateAssetsMeta: true, GenerateAssetsThumb: true, GenerateAssetsTeaser: true, GenerateAssetsSprites: false, GenerateAssetsAnalyze: false, EnrichPostworkEnabled: true, TrainingRecognitionEnabled: true, TrainingDetectorEpochs: 60, EncryptedCookies: "", } settingsFile = "recorder_settings.json" ) const ( defaultRecordDir = "record" defaultDoneDir = "record/done" legacyDefaultRecordDir = "/records" legacyDefaultDoneDir = "/records/done" ) func normalizeSettingsDir(raw string, fallback string, legacyDefaults ...string) string { path := strings.TrimSpace(raw) if path == "" { return fallback } path = filepath.Clean(filepath.FromSlash(path)) for _, legacy := range legacyDefaults { if path == filepath.Clean(filepath.FromSlash(strings.TrimSpace(legacy))) { return fallback } } return path } 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) appLogln("🔧 settingsFile:", p) if err == nil { s := getSettings() // ✅ startet mit Defaults if json.Unmarshal(b, &s) == nil { s.RecordDir = normalizeSettingsDir(s.RecordDir, defaultRecordDir, legacyDefaultRecordDir) s.DoneDir = normalizeSettingsDir(s.DoneDir, defaultDoneDir, legacyDefaultDoneDir) s.DatabaseType = normalizeDatabaseType(s.DatabaseType) normalizePostgresSettings(&s) 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 } if s.AutoDeleteLowRatedDownloadsMaxStars < 1 { s.AutoDeleteLowRatedDownloadsMaxStars = 3 } if s.AutoDeleteLowRatedDownloadsMaxStars > 5 { s.AutoDeleteLowRatedDownloadsMaxStars = 5 } if s.LowDiskPauseBelowGB < 1 { s.LowDiskPauseBelowGB = 1 } if s.LowDiskPauseBelowGB > 10_000 { s.LowDiskPauseBelowGB = 10_000 } if s.TrainingDetectorEpochs < 1 { s.TrainingDetectorEpochs = 60 } if s.TrainingDetectorEpochs > 300 { s.TrainingDetectorEpochs = 300 } settingsMu.Lock() settings = s settingsMu.Unlock() } s.DatabaseType = normalizeDatabaseType(s.DatabaseType) normalizePostgresSettings(&s) 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 } } } // ✅ WICHTIG: Migrationsergebnis zurück in den globalen settings-State schreiben settingsMu.Lock() settings = s settingsMu.Unlock() // optional aber sinnvoll: Migration auch persistieren saveSettingsToDisk() } // 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() appLogln("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() appLogln("🔍 ffprobePath:", ffprobePath) } func saveSettingsToDisk() { s := getSettings() b, err := json.MarshalIndent(s, "", " ") if err != nil { appLogln("⚠️ settings marshal:", err) return } b = append(b, '\n') p := settingsFilePath() if err := atomicWriteFile(p, b); err != nil { appLogln("⚠️ settings write:", err) return } // optional // appLogln("✅ settings saved:", p) } type RecorderSettingsPublic struct { RecordDir string `json:"recordDir"` DoneDir string `json:"doneDir"` FFmpegPath string `json:"ffmpegPath"` DatabaseType string `json:"databaseType"` PostgresMode string `json:"postgresMode"` PostgresDataDir string `json:"postgresDataDir"` PostgresPort int `json:"postgresPort"` PostgresDatabase string `json:"postgresDatabase"` PostgresUser string `json:"postgresUser"` DatabaseURL string `json:"databaseUrl"` HasDBPassword bool `json:"hasDbPassword"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"` MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` UseProviderAPIs bool `json:"useProviderApis"` UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsAPI bool `json:"useMyFreeCamsApi"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` AutoDeleteLowRatedDownloads bool `json:"autoDeleteLowRatedDownloads"` AutoDeleteLowRatedDownloadsMaxStars int `json:"autoDeleteLowRatedDownloadsMaxStars"` LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` TeaserAudio bool `json:"teaserAudio"` EnableNotifications bool `json:"enableNotifications"` GenerateAssetsMeta bool `json:"generateAssetsMeta"` GenerateAssetsThumb bool `json:"generateAssetsThumb"` GenerateAssetsTeaser bool `json:"generateAssetsTeaser"` GenerateAssetsSprites bool `json:"generateAssetsSprites"` GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"` TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` } func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { normalizePostgresSettings(&s) return RecorderSettingsPublic{ RecordDir: s.RecordDir, DoneDir: s.DoneDir, FFmpegPath: s.FFmpegPath, DatabaseType: normalizeDatabaseType(s.DatabaseType), PostgresMode: s.PostgresMode, PostgresDataDir: s.PostgresDataDir, PostgresPort: s.PostgresPort, PostgresDatabase: s.PostgresDatabase, PostgresUser: s.PostgresUser, DatabaseURL: strings.TrimSpace(s.DatabaseURL), HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "", AutoAddToDownloadList: s.AutoAddToDownloadList, AutoStartAddedDownloads: s.AutoStartAddedDownloads, EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit, MaxConcurrentDownloads: s.MaxConcurrentDownloads, UseProviderAPIs: s.UseProviderAPIs, UseChaturbateAPI: s.UseChaturbateAPI, UseMyFreeCamsAPI: s.UseMyFreeCamsAPI, AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites, AutoDeleteLowRatedDownloads: s.AutoDeleteLowRatedDownloads, AutoDeleteLowRatedDownloadsMaxStars: s.AutoDeleteLowRatedDownloadsMaxStars, LowDiskPauseBelowGB: s.LowDiskPauseBelowGB, BlurPreviews: s.BlurPreviews, TeaserPlayback: s.TeaserPlayback, TeaserAudio: s.TeaserAudio, EnableNotifications: s.EnableNotifications, GenerateAssetsMeta: s.GenerateAssetsMeta, GenerateAssetsThumb: s.GenerateAssetsThumb, GenerateAssetsTeaser: s.GenerateAssetsTeaser, GenerateAssetsSprites: s.GenerateAssetsSprites, GenerateAssetsAnalyze: s.GenerateAssetsAnalyze, EnrichPostworkEnabled: s.EnrichPostworkEnabled, TrainingRecognitionEnabled: s.TrainingRecognitionEnabled, TrainingDetectorEpochs: s.TrainingDetectorEpochs, } } 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 } if in.AutoDeleteLowRatedDownloadsMaxStars < 1 { in.AutoDeleteLowRatedDownloadsMaxStars = 3 } if in.AutoDeleteLowRatedDownloadsMaxStars > 5 { in.AutoDeleteLowRatedDownloadsMaxStars = 5 } if in.LowDiskPauseBelowGB < 1 { in.LowDiskPauseBelowGB = 1 } if in.LowDiskPauseBelowGB > 10_000 { in.LowDiskPauseBelowGB = 10_000 } if in.MaxConcurrentDownloads < 1 { in.MaxConcurrentDownloads = 1 } if in.TrainingDetectorEpochs < 1 { in.TrainingDetectorEpochs = 60 } if in.TrainingDetectorEpochs > 300 { in.TrainingDetectorEpochs = 300 } // --- 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.DatabaseType = normalizeDatabaseType(in.DatabaseType) normalizePostgresSettings(&in.RecorderSettings) in.DatabaseURL = strings.TrimSpace(in.DatabaseURL) if in.PostgresMode == postgresModeURL && in.DatabaseURL == "" { http.Error(w, "Database URL fehlt", http.StatusBadRequest) return } // 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren // und URL ohne Passwort zurückschreiben. sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(in.DatabaseURL) pwFromURL = strings.TrimSpace(pwFromURL) if pwFromURL == "****" { pwFromURL = "" } if sanitizedURL != "" { in.DatabaseURL = sanitizedURL } // 3) Wenn Frontend ein Passwort sendet, hat das Priorität. current := getSettings() plainPW := strings.TrimSpace(in.DBPassword) if plainPW == "" { plainPW = pwFromURL } 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 } else { in.EncryptedDBPassword = current.EncryptedDBPassword } // Nicht komplette Settings stumpf überschreiben, // sondern bestehende Settings nehmen und gezielt aktualisieren. next := current next.RecordDir = in.RecordDir next.DoneDir = in.DoneDir next.FFmpegPath = in.FFmpegPath next.DatabaseType = in.DatabaseType next.PostgresMode = in.PostgresMode next.PostgresDataDir = in.PostgresDataDir next.PostgresPort = in.PostgresPort next.PostgresDatabase = in.PostgresDatabase next.PostgresUser = in.PostgresUser next.DatabaseURL = in.DatabaseURL next.EncryptedDBPassword = in.EncryptedDBPassword next.AutoAddToDownloadList = in.AutoAddToDownloadList next.AutoStartAddedDownloads = in.AutoStartAddedDownloads next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit next.MaxConcurrentDownloads = in.MaxConcurrentDownloads next.UseProviderAPIs = in.UseProviderAPIs next.UseChaturbateAPI = in.UseChaturbateAPI next.UseMyFreeCamsAPI = in.UseMyFreeCamsAPI next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites next.AutoDeleteLowRatedDownloads = in.AutoDeleteLowRatedDownloads next.AutoDeleteLowRatedDownloadsMaxStars = in.AutoDeleteLowRatedDownloadsMaxStars next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB next.BlurPreviews = in.BlurPreviews next.TeaserPlayback = in.TeaserPlayback next.TeaserAudio = in.TeaserAudio next.EnableNotifications = in.EnableNotifications oldEnrichPostworkEnabled := current.EnrichPostworkEnabled next.GenerateAssetsMeta = in.GenerateAssetsMeta next.GenerateAssetsThumb = in.GenerateAssetsThumb next.GenerateAssetsTeaser = in.GenerateAssetsTeaser next.GenerateAssetsSprites = in.GenerateAssetsSprites next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze next.EnrichPostworkEnabled = in.EnrichPostworkEnabled next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled next.TrainingDetectorEpochs = in.TrainingDetectorEpochs dbChanged := normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) || postgresSettingsKey(next) != postgresSettingsKey(current) || strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) var newStore *ModelStore // DB erst prüfen, BEVOR gespeichert wird if dbChanged { storeConfig, err := buildModelStoreConfigFromRecorderSettings(next) if err != nil { http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest) return } newStore = NewModelStore(storeConfig) if err := newStore.Load(); err != nil { _ = newStore.Close() http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest) return } } // Erst jetzt wirklich übernehmen settingsMu.Lock() settings = next settingsMu.Unlock() saveSettingsToDisk() if oldEnrichPostworkEnabled && !next.EnrichPostworkEnabled { cancelledQueued, cancelledRunning := enrichQ.CancelAll() if cancelledQueued > 0 || cancelledRunning > 0 { appLogln( "🛑 enrich postwork deaktiviert:", "queuedRemoved=", cancelledQueued, "runningCancelled=", cancelledRunning, ) } } if newStore != nil { setModelStore(newStore) setChaturbateOnlineModelStore(newStore) } // ✅ 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() } //appLogln("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() //appLogln("🔍 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" && target != "postgresData" { http.Error(w, "target muss record, done, ffmpeg oder postgresData 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 if target == "postgresData" { p, err = dialog.Directory(). Title("PostgreSQL-Datenordner auswaehlen"). Browse() } 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 }