package web import ( "fmt" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "golang.org/x/crypto/bcrypt" ) // DeployStoragePath extends StoragePath with free space data for the deploy dropdown. type DeployStoragePath struct { settings.StoragePath FreeHuman string // "234.5 GB" FreePercent float64 // 67.5 } // StorageAppDetail holds info about an app using a specific storage path. type StorageAppDetail struct { Name string // Display name (e.g., "Immich") Stack string // Stack name (for link) SizeHuman string // Data size on this path } // StoragePathView extends StoragePath with display data for the settings page. type StoragePathView struct { settings.StoragePath DiskInfo *system.DiskUsageInfo AppCount int IsMounted bool AppDetails []StorageAppDetail FSInfo *system.FSInfo } func (s *Server) baseData(page, title string) map[string]interface{} { data := map[string]interface{}{ "Page": page, "Title": title, "CustomerName": s.cfg.Customer.Name, "Domain": s.cfg.Customer.Domain, "Version": s.version, "AuthEnabled": s.authEnabled(), } if s.alertManager != nil { data["Alerts"] = s.alertManager.GetAlerts() } return data } func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) { stackList := s.stackMgr.GetStacks() // Filter to deployed + protected stacks first var deployedStacks []stacks.Stack for _, st := range stackList { if st.Deployed || st.Protected { deployedStacks = append(deployedStacks, st) } } // Count from the DISPLAYED set only running, stopped := 0, 0 for _, st := range deployedStacks { switch st.State { case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: running++ case stacks.StateStopped, stacks.StateExited: stopped++ } } sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data := s.baseData("dashboard", "Vezérlőpult") data["Stacks"] = deployedStacks data["RunningCount"] = running data["StoppedCount"] = stopped data["TotalCount"] = len(stackList) data["SystemInfo"] = sysInfo // Backup status data["BackupEnabled"] = s.cfg.Backup.Enabled if s.backupMgr != nil { nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule) fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup) data["DBDumpStatus"] = fullStatus.LastDBDump data["BackupStatus"] = fullStatus.LastBackup data["BackupRunning"] = fullStatus.Running data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours } s.render(w, "dashboard", data) } func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) { data := s.baseData("stacks", "Alkalmazások") data["Stacks"] = s.stackMgr.GetStacks() // Build storage label lookup for deployed apps storageLabels := make(map[string]string) // stack name → storage label storagePaths := s.settings.GetStoragePaths() for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" { for _, sp := range storagePaths { if sp.Path == hddPath { storageLabels[stack.Name] = sp.Label break } } } } } data["StorageLabels"] = storageLabels s.render(w, "stacks", data) } func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) { stack, ok := s.stackMgr.GetStack(name) if !ok { http.NotFound(w, r) return } logs, err := s.stackMgr.GetLogs(name, 200) if err != nil { logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err) } // Raw mode: return plain text for AJAX polling if r.URL.Query().Get("raw") == "1" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprint(w, logs) return } data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók") data["Stack"] = stack data["Logs"] = logs s.render(w, "logs", data) } func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) { meta, appCfg, err := s.stackMgr.GetDeployFields(name) if err != nil { http.NotFound(w, r) return } stack, _ := s.stackMgr.GetStack(name) alreadyDeployed := appCfg != nil && appCfg.Deployed pageTitle := meta.DisplayName + " — Telepítés" if alreadyDeployed { pageTitle = meta.DisplayName + " — Beállítások" } data := s.baseData("deploy", pageTitle) data["Stack"] = stack data["Meta"] = meta data["AppConfig"] = appCfg data["AlreadyDeployed"] = alreadyDeployed data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug) data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug) data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) data["UserFields"] = meta.UserFacingFields() data["AutoFields"] = meta.AutoGeneratedFields() // Storage paths with free space info for deploy dropdown var deployPaths []DeployStoragePath for _, sp := range s.settings.GetSchedulableStoragePaths() { dp := DeployStoragePath{StoragePath: sp} if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = di.AvailGB / di.TotalGB * 100 } } deployPaths = append(deployPaths, dp) } data["StoragePaths"] = deployPaths // Storage info for already-deployed apps with HDD data if alreadyDeployed { storageInfo := s.storageInfoForStack(name) if storageInfo != nil { data["StorageInfo"] = storageInfo data["OtherStoragePaths"] = s.otherStoragePathsForStack(name) } // Stale data from previous migrations (only for deployed apps with HDD data) staleData := s.findStaleStorageData(name) if len(staleData) > 0 { data["StaleData"] = staleData } // Cross-drive backup config for this app crossCfg := s.settings.GetCrossDriveConfig(name) data["CrossDriveConfig"] = crossCfg // Other storage paths for destination dropdown (exclude the app's current storage path) currentPath := "" if storageInfo != nil { currentPath = storageInfo.Path } var destPaths []DeployStoragePath for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentPath { continue // skip the app's current storage — must be a DIFFERENT physical device } dp := DeployStoragePath{StoragePath: sp} if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = di.AvailGB / di.TotalGB * 100 } } destPaths = append(destPaths, dp) } data["BackupDestPaths"] = destPaths // Destination health warning (tiered validation) if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" { health := system.CheckBackupDestination(crossCfg.DestinationPath) if health.Warning != "" { data["BackupDestWarning"] = health.Warning data["BackupDestWarningSeverity"] = health.Severity } } } // Memory info for deploy page (only for non-deployed apps) if !alreadyDeployed { memInfo := map[string]interface{}{"Available": false} totalMB, memErr := system.GetTotalMemoryMB() if memErr == nil { reservedMB := s.cfg.System.ReservedMemoryMB usableMB := totalMB - reservedMB committedReqMB, committedLimitMB := s.stackMgr.CommittedMemory() newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest) newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit) afterReqMB := committedReqMB + newReqMB afterLimitMB := committedLimitMB + newLimitMB percent := 0 if usableMB > 0 { percent = afterReqMB * 100 / usableMB } committedPercent := 0 if usableMB > 0 { committedPercent = committedReqMB * 100 / usableMB } memInfo["Available"] = true memInfo["TotalMB"] = totalMB memInfo["ReservedMB"] = reservedMB memInfo["UsableMB"] = usableMB memInfo["CommittedMB"] = committedReqMB memInfo["NewRequestMB"] = newReqMB memInfo["AfterMB"] = afterReqMB memInfo["Percent"] = percent memInfo["CommittedPercent"] = committedPercent memInfo["Blocked"] = newReqMB > 0 && afterReqMB > usableMB memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB } data["MemoryInfo"] = memInfo } // Flash messages from cross-drive backup save redirect if flash := r.URL.Query().Get("flash"); flash != "" { data["FlashSuccess"] = flash } if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { data["FlashError"] = flashErr } s.render(w, "deploy", data) } func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) { var found *stacks.Stack for _, stack := range s.stackMgr.GetStacks() { if stack.Meta.Slug == slug { found = &stack break } } if found == nil { http.NotFound(w, r) return } // Load current optional config values from app.yaml currentValues := make(map[string]string) if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil { for k, v := range appCfg.Env { currentValues[k] = v } } data := s.baseData("stacks", found.Meta.DisplayName) data["Stack"] = found data["Meta"] = found.Meta data["AppInfo"] = found.Meta.AppInfo data["OptionalConfig"] = found.Meta.OptionalConfig data["CurrentValues"] = currentValues data["HasAppInfo"] = found.Meta.HasAppInfo() data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() s.render(w, "app_info", data) } func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) { data := s.baseData("monitoring", "Rendszermonitor") data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) // On monitoring page, exclude the "pings-missing" alert since the detailed table is visible if s.alertManager != nil { data["Alerts"] = s.alertManager.GetAlerts("pings-missing") } // Ping status section data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled if s.cfg.Monitoring.Enabled { pings := []map[string]interface{}{ {"Label": "Életjel (Heartbeat)", "Icon": "💓", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenként"}, {"Label": "Rendszer állapot", "Icon": "🖥️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenként"}, {"Label": "Adatbázis mentés", "Icon": "🗄️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule}, {"Label": "Biztonsági mentés", "Icon": "💾", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule}, {"Label": "Mentés integritás", "Icon": "🔍", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasárnap)"}, } allConfigured := true for _, p := range pings { if !p["Configured"].(bool) { allConfigured = false break } } data["PingStatus"] = pings data["AllPingsConfigured"] = allConfigured } s.render(w, "monitoring", data) } // isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder. func isPingConfigured(uuid string) bool { return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME") } func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("backups", "Biztonsági mentés") // System info for storage overview bars data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) if s.backupMgr != nil { nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule) fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup) // Pass flash messages from query params (set by redirect handlers) if flash := r.URL.Query().Get("flash"); flash != "" { fullStatus.FlashSuccess = flash } if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { fullStatus.FlashError = flashErr } // Enrich AppDataInfo with storage labels storagePaths := s.settings.GetStoragePaths() for i := range fullStatus.AppDataInfo { app := &fullStatus.AppDataInfo[i] if len(app.HDDPaths) > 0 { hddPath := app.HDDPaths[0].HostPath // Match HDD path prefix against registered storage paths for _, sp := range storagePaths { if strings.HasPrefix(hddPath, sp.Path) { app.StorageLabel = sp.Label break } } } } // Build cross-drive summary crossConfigs := s.settings.GetAllCrossDriveConfigs() // Build label lookup for dest paths destLabels := make(map[string]string) for _, sp := range storagePaths { destLabels[sp.Path] = sp.Label } for _, app := range fullStatus.AppDataInfo { if !app.HasHDDData { continue } cfg, hasCfg := crossConfigs[app.StackName] if !hasCfg || cfg == nil { fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{ StackName: app.StackName, DisplayName: app.DisplayName, }) continue } item := backup.CrossDriveSummaryItem{ StackName: app.StackName, DisplayName: app.DisplayName, Method: cfg.Method, DestPath: cfg.DestinationPath, DestLabel: destLabels[cfg.DestinationPath], Schedule: cfg.Schedule, LastStatus: cfg.LastStatus, SizeHuman: cfg.LastSizeHuman, } switch cfg.Method { case "rsync": item.MethodLabel = "rsync" case "restic": item.MethodLabel = "restic" default: item.MethodLabel = cfg.Method } switch cfg.Schedule { case "daily": item.ScheduleLabel = "Naponta" case "weekly": item.ScheduleLabel = "Hetente" default: item.ScheduleLabel = "Kézi" } if cfg.LastRun != "" { if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil { loc, _ := time.LoadLocation("Europe/Budapest") item.LastRunShort = t.In(loc).Format("01-02 15:04") } } fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item) // Destination health warning (tiered validation) if cfg.Enabled && cfg.DestinationPath != "" { health := system.CheckBackupDestination(cfg.DestinationPath) if health.Warning != "" { prefix := "⚠️" if health.Severity == "critical" { prefix = "🔴" } fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings, fmt.Sprintf("%s %s: %s", prefix, app.DisplayName, health.Warning)) } } } // Build unified per-app backup rows for the new UI data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, crossConfigs, destLabels) // Top-level warning: no user data backed up at all hasAnyCrossDrive := false hasAnyHDDApp := false for _, app := range fullStatus.AppDataInfo { if app.HasHDDData { hasAnyHDDApp = true if cfg, ok := crossConfigs[app.StackName]; ok && cfg != nil && cfg.Enabled { hasAnyCrossDrive = true } } } if hasAnyHDDApp && !hasAnyCrossDrive { data["NoUserDataBackupWarning"] = true } data["Backup"] = fullStatus // Restic password for display if pw, err := s.backupMgr.GetResticPassword(); err == nil { data["ResticPassword"] = pw } } else { data["Backup"] = nil } s.render(w, "backups", data) } // AppBackupRow holds all backup information for one app, used by the backup page template. type AppBackupRow struct { StackName string DisplayName string Status string // "green", "yellow", "red", "auto" StatusText string // short Hungarian tooltip // Storage info (HDD apps only) HasHDDData bool StorageLabel string HDDSizeHuman string // Layer details (nil = layer not applicable) HasDB bool DBLastRun string // formatted time DBLastStatus string // "ok", "error", "" VolumeLastRun string VolumeLastStatus string // Cross-drive / user data HasUserData bool UserDataConfigured bool UserDataMethod string // "rsync", "restic" UserDataDest string // destination label UserDataSchedule string // "Naponta", "Hetente" UserDataLastRun string UserDataLastStatus string // "ok", "error", "running", "" UserDataLastError string UserDataStatusBadge string // "Sikeres", "Hiba", "Fut...", "—" // Warnings accumulated for this app Warnings []string } // buildAppBackupRows constructs one AppBackupRow per deployed app for the unified backup page. func (s *Server) buildAppBackupRows( status *backup.FullBackupStatus, crossConfigs map[string]*settings.CrossDriveBackup, destLabels map[string]string, ) []AppBackupRow { loc, _ := time.LoadLocation("Europe/Budapest") // Build a quick lookup: which stacks have a DB dump? dbStacks := make(map[string]bool) for _, db := range status.DiscoveredDBs { dbStacks[db.StackName] = true } // Also check dump files if no live discovered DBs for _, f := range status.DumpFiles { dbStacks[f.StackName] = true } // Determine last restic run time for volume backup display volumeLastRun := "" volumeLastStatus := "" if status.LastBackup != nil { volumeLastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04") if status.LastBackup.Success { volumeLastStatus = "ok" } else { volumeLastStatus = "error" } } // DB dump last run dbLastRun := "" dbLastStatus := "" if status.LastDBDump != nil { dbLastRun = status.LastDBDump.LastRun.In(loc).Format("01-02 15:04") if status.LastDBDump.Success { dbLastStatus = "ok" } else { dbLastStatus = "error" } } var rows []AppBackupRow for _, app := range status.AppDataInfo { row := AppBackupRow{ StackName: app.StackName, DisplayName: app.DisplayName, HasHDDData: app.HasHDDData, StorageLabel: app.StorageLabel, HDDSizeHuman: app.HDDSizeHuman, HasDB: dbStacks[app.StackName] || app.HasDBDump, DBLastRun: dbLastRun, DBLastStatus: dbLastStatus, VolumeLastRun: volumeLastRun, VolumeLastStatus: volumeLastStatus, } // Default status = green/auto row.Status = "auto" row.StatusText = "Automatikus mentés" if app.HasHDDData { row.HasUserData = true cfg, hasCfg := crossConfigs[app.StackName] if !hasCfg || cfg == nil || !cfg.Enabled { // HDD data but no cross-drive configured → RED row.UserDataConfigured = false row.Status = "red" row.StatusText = "Felhasználói adatokról nincs mentés" } else { row.UserDataConfigured = true row.UserDataMethod = cfg.Method row.UserDataDest = destLabels[cfg.DestinationPath] if row.UserDataDest == "" { row.UserDataDest = cfg.DestinationPath } switch cfg.Schedule { case "daily": row.UserDataSchedule = "Naponta" case "weekly": row.UserDataSchedule = "Hetente (vasárnap)" default: row.UserDataSchedule = cfg.Schedule } if cfg.LastRun != "" { if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil { row.UserDataLastRun = t.In(loc).Format("01-02 15:04") } } row.UserDataLastStatus = cfg.LastStatus row.UserDataLastError = cfg.LastError switch cfg.LastStatus { case "ok": row.UserDataStatusBadge = "Sikeres" case "error": row.UserDataStatusBadge = "Hiba" case "running": row.UserDataStatusBadge = "Fut..." default: row.UserDataStatusBadge = "—" } // Check destination health for status determination health := system.CheckBackupDestination(cfg.DestinationPath) if health.Blocked { row.Status = "red" row.StatusText = "Mentési cél nem elérhető" row.Warnings = append(row.Warnings, health.Warning) } else if health.Warning != "" { row.Status = "yellow" row.StatusText = "Figyelmeztetés" row.Warnings = append(row.Warnings, health.Warning) } else if cfg.LastStatus == "error" { row.Status = "yellow" row.StatusText = "Utolsó mentés sikertelen" } else { row.Status = "green" row.StatusText = "Mentés rendben" } } } else { // No HDD data — fully automatic row.Status = "auto" row.StatusText = "Automatikus mentés (nincs felhasználói adat)" } // If DB dump failed for this app, degrade to yellow (if not already red) if row.HasDB && dbLastStatus == "error" && row.Status != "red" { row.Status = "yellow" row.StatusText = "Adatbázis mentés sikertelen" } rows = append(rows, row) } return rows } // settingsCrossBackupHandler handles POST /settings/cross-backup/{name} // Saves or updates the cross-drive backup configuration for an app. func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) { _ = r.ParseForm() enabled := r.FormValue("cross_drive_enabled") == "on" // Preserve existing runtime status fields and config when disabling existing := s.settings.GetCrossDriveConfig(name) var method, destPath, schedule string if enabled { method = r.FormValue("cross_drive_method") destPath = r.FormValue("cross_drive_dest") schedule = r.FormValue("cross_drive_schedule") // Validate method and schedule if method != "rsync" && method != "restic" { method = "rsync" } if schedule != "daily" && schedule != "weekly" { schedule = "daily" } } else if existing != nil { // Preserve existing settings when disabling method = existing.Method destPath = existing.DestinationPath schedule = existing.Schedule } var cfg *settings.CrossDriveBackup if destPath != "" || existing != nil { cfg = &settings.CrossDriveBackup{ Enabled: enabled, Method: method, DestinationPath: destPath, Schedule: schedule, } if existing != nil { cfg.LastRun = existing.LastRun cfg.LastStatus = existing.LastStatus cfg.LastError = existing.LastError cfg.LastDuration = existing.LastDuration cfg.LastSizeHuman = existing.LastSizeHuman } } if err := s.settings.SetCrossDriveConfig(name, cfg); err != nil { s.logger.Printf("[ERROR] Failed to save cross-drive config for %s: %v", name, err) http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound) return } s.logger.Printf("[INFO] Cross-drive backup config saved for %s: method=%s dest=%s schedule=%s enabled=%v", name, method, destPath, schedule, enabled) http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound) } func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() stackName := r.FormValue("stack_name") snapshotID := r.FormValue("snapshot_id") if stackName == "" || snapshotID == "" { http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) return } if s.backupMgr == nil { http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil { s.logger.Printf("[ERROR] Restore failed: %v", err) errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error()) http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound) return } msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").") http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound) } func (s *Server) settingsData() map[string]interface{} { data := s.baseData("settings", "Beállítások") data["CustomerID"] = s.cfg.Customer.ID data["CustomerDomain"] = s.cfg.Customer.Domain data["GitRepoURL"] = s.cfg.Git.RepoURL data["GitSyncInterval"] = s.cfg.Git.SyncInterval data["BackupEnabled"] = s.cfg.Backup.Enabled data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule data["ResticSchedule"] = s.cfg.Backup.ResticSchedule data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase data["HubEnabled"] = s.cfg.Hub.Enabled data["NotificationPrefs"] = s.settings.GetNotificationPrefs() // Storage paths with display data storagePaths := s.settings.GetStoragePaths() var storageViews []StoragePathView for _, sp := range storagePaths { view := StoragePathView{ StoragePath: sp, IsMounted: system.IsMountPoint(sp.Path), AppDetails: s.appDetailsForPath(sp.Path), FSInfo: system.GetFSInfo(sp.Path), } view.AppCount = len(view.AppDetails) if di := system.GetDiskUsage(sp.Path); di != nil { view.DiskInfo = di } storageViews = append(storageViews, view) } data["StoragePaths"] = storageViews return data } func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) { data := s.settingsData() if msg := r.URL.Query().Get("storage_msg"); msg == "success" { data["StorageSuccess"] = r.URL.Query().Get("storage_detail") } s.render(w, "settings", data) } func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() currentPassword := r.FormValue("current_password") newPassword := r.FormValue("new_password") confirmPassword := r.FormValue("confirm_password") data := s.settingsData() // Validate current password effectiveHash := s.effectivePasswordHash() if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil { data["PasswordError"] = "Hibás jelenlegi jelszó" s.render(w, "settings", data) return } // Validate new password length if len(newPassword) < 8 { data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie" s.render(w, "settings", data) return } // Validate passwords match if newPassword != confirmPassword { data["PasswordError"] = "A két jelszó nem egyezik" s.render(w, "settings", data) return } // Generate bcrypt hash hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10) if err != nil { s.logger.Printf("[ERROR] Failed to hash new password: %v", err) data["PasswordError"] = "Belső hiba a jelszó mentésekor" s.render(w, "settings", data) return } // Save to settings.json if err := s.settings.SetPasswordHash(string(hash)); err != nil { s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err) data["PasswordError"] = "Belső hiba a jelszó mentésekor" s.render(w, "settings", data) return } s.logger.Printf("[INFO] Password changed via settings page from %s", r.RemoteAddr) // Invalidate all sessions (force re-login) s.invalidateAllSessions() // Redirect to login with flash message flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.") http.Redirect(w, r, "/login?flash="+flash, http.StatusFound) } func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() email := strings.TrimSpace(r.FormValue("notification_email")) cooldownStr := r.FormValue("cooldown_hours") cooldownHours := 6 if cooldownStr != "" { if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil { cooldownHours = 6 } } if cooldownHours < 1 { cooldownHours = 1 } if cooldownHours > 168 { cooldownHours = 168 } // Collect enabled events from checkboxes var enabledEvents []string for _, evt := range []string{"disk_warning", "backup_failed", "update_available", "security_update"} { if r.FormValue("event_"+evt) == "on" { enabledEvents = append(enabledEvents, evt) } } prefs := &settings.NotificationPrefs{ Email: email, EnabledEvents: enabledEvents, CooldownHours: cooldownHours, } if err := s.settings.SetNotificationPrefs(prefs); err != nil { s.logger.Printf("[ERROR] Failed to save notification prefs: %v", err) data := s.settingsData() data["NotificationError"] = "Hiba a beállítások mentésekor" s.render(w, "settings", data) return } s.logger.Printf("[INFO] Notification preferences updated: email=%s, events=%v", email, enabledEvents) // Sync preferences to hub data := s.settingsData() if s.notifier != nil && s.notifier.IsEnabled() { if err := s.notifier.SyncPreferences(email, enabledEvents); err != nil { s.logger.Printf("[WARN] Failed to sync preferences to hub: %v", err) data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err) } else { data["NotificationSuccess"] = "Értesítési beállítások mentve." } } else { data["NotificationSuccess"] = "Értesítési beállítások mentve." } s.render(w, "settings", data) } func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) { data := s.settingsData() if s.notifier == nil { data["NotificationError"] = "Az értesítések nincsenek bekapcsolva" s.render(w, "settings", data) return } err := s.notifier.SendTest() if err != nil { s.logger.Printf("[ERROR] Test notification failed: %v", err) data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err) s.render(w, "settings", data) return } data["NotificationSuccess"] = "Teszt email elküldve." s.render(w, "settings", data) } // --- Storage path management handlers --- func (s *Server) countAppsUsingPath(storagePath string) int { count := 0 for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if appCfg.Env["HDD_PATH"] == storagePath { count++ } } } return count } func (s *Server) appsUsingPath(storagePath string) []string { var names []string for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if appCfg.Env["HDD_PATH"] == storagePath { names = append(names, stack.Meta.DisplayName) } } } return names } func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail { var details []StorageAppDetail for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } appCfg := s.stackMgr.LoadAppConfigByName(stack.Name) if appCfg == nil { continue } hddPath := appCfg.Env["HDD_PATH"] if hddPath != storagePath { continue } detail := StorageAppDetail{ Name: stack.Meta.DisplayName, Stack: stack.Meta.Slug, } // Try to get data size from the storage subdirectory appDataDir := filepath.Join(storagePath, "storage", stack.Name) if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() { detail.SizeHuman = dirSizeHuman(appDataDir) } details = append(details, detail) } return details } // dirSizeHuman returns a human-readable size for a directory. func dirSizeHuman(path string) string { var total int64 filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } total += info.Size() return nil }) const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case total >= GB: return fmt.Sprintf("%.1f GB", float64(total)/float64(GB)) case total >= MB: return fmt.Sprintf("%.1f MB", float64(total)/float64(MB)) case total >= KB: return fmt.Sprintf("%.1f KB", float64(total)/float64(KB)) default: return fmt.Sprintf("%d B", total) } } func formatFreeSpace(gb float64) string { if gb >= 1000 { return fmt.Sprintf("%.1f TB", gb/1024) } return fmt.Sprintf("%.1f GB", gb) } func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := filepath.Clean(r.FormValue("storage_path")) label := strings.TrimSpace(r.FormValue("storage_label")) isDefault := r.FormValue("storage_default") == "true" if label == "" { label = settings.InferStorageLabel(path) } data := s.settingsData() // 1. Exists and is directory fi, err := os.Stat(path) if err != nil || !fi.IsDir() { data["StorageError"] = "Az útvonal nem létezik vagy nem mappa." s.render(w, "settings", data) return } // 2. Is mount point if !system.IsMountPoint(path) { data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!" s.render(w, "settings", data) return } // 3. Writable if !system.IsWritable(path) { data["StorageError"] = "Az útvonal nem írható." s.render(w, "settings", data) return } // 4. No overlap with existing paths for _, existing := range s.settings.GetStoragePaths() { if system.PathsOverlap(path, existing.Path) { data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path) s.render(w, "settings", data) return } } // 5. Soft warning if not under /mnt/ if !strings.HasPrefix(path, "/mnt/") { s.logger.Printf("[WARN] Storage path %s is not under /mnt/ — unusual but allowed", path) } sp := settings.StoragePath{ Path: path, Label: label, IsDefault: isDefault, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } if err := s.settings.AddStoragePath(sp); err != nil { s.logger.Printf("[ERROR] Failed to add storage path: %v", err) data["StorageError"] = "Hiba a mentés során." s.render(w, "settings", data) return } s.logger.Printf("[INFO] Storage path added: %s (%s)", path, label) http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound) } func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") data := s.settingsData() // Check: apps using this path apps := s.appsUsingPath(path) if len(apps) > 0 { data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", ")) s.render(w, "settings", data) return } // Check: cannot remove default for _, sp := range s.settings.GetStoragePaths() { if sp.Path == path && sp.IsDefault { data["StorageError"] = "Az alapértelmezett adattároló nem törölhető." s.render(w, "settings", data) return } } // Check: last path if len(s.settings.GetStoragePaths()) <= 1 { data["StorageError"] = "Az utolsó adattároló nem törölhető." s.render(w, "settings", data) return } if err := s.settings.RemoveStoragePath(path); err != nil { data["StorageError"] = "Hiba a törlés során." s.render(w, "settings", data) return } s.logger.Printf("[INFO] Storage path removed: %s", path) // Sync FileBrowser mounts after storage path removal go s.syncFileBrowserMounts() http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound) } func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") if err := s.settings.SetDefaultStoragePath(path); err != nil { s.logger.Printf("[ERROR] Failed to set default storage path: %v", err) http.Redirect(w, r, "/settings", http.StatusFound) return } http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound) } func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") schedulable := r.FormValue("schedulable") == "true" if err := s.settings.SetSchedulable(path, schedulable); err != nil { s.logger.Printf("[ERROR] Failed to update schedulable: %v", err) http.Redirect(w, r, "/settings", http.StatusFound) return } http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound) } func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") label := strings.TrimSpace(r.FormValue("storage_label")) if label == "" || len(label) > 50 { data := s.settingsData() data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter." s.render(w, "settings", data) return } if err := s.settings.SetStorageLabel(path, label); err != nil { s.logger.Printf("[ERROR] Failed to set storage label: %v", err) data := s.settingsData() data["StorageError"] = "Hiba a megnevezés mentésekor." s.render(w, "settings", data) return } s.logger.Printf("[INFO] Storage label updated: %s → %q", path, label) http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound) } // syncFileBrowserMounts regenerates FileBrowser's docker-compose.yml // with volume mounts for all registered storage paths, then recreates the container. func (s *Server) syncFileBrowserMounts() { composePath := "/opt/docker/stacks/filebrowser/docker-compose.yml" // Check if FileBrowser stack exists if _, err := os.Stat(composePath); os.IsNotExist(err) { s.logger.Printf("[WARN] FileBrowser stack not found at %s — skipping mount sync", composePath) return } // Get all active storage paths paths := s.settings.GetStoragePaths() // Read domain from .env envPath := "/opt/docker/stacks/filebrowser/.env" domain := "" if data, err := os.ReadFile(envPath); err == nil { for _, line := range strings.Split(string(data), "\n") { if strings.HasPrefix(line, "DOMAIN=") { domain = strings.TrimPrefix(line, "DOMAIN=") break } } } if domain == "" { s.logger.Printf("[WARN] Cannot read DOMAIN from FileBrowser .env — skipping mount sync") return } // Build volume mount lines var storageMounts []string for _, sp := range paths { mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1" line := fmt.Sprintf(" - %s:/srv/%s", sp.Path, mountName) storageMounts = append(storageMounts, line) } // Generate compose from template compose := generateFileBrowserCompose(domain, storageMounts) // Write compose if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil { s.logger.Printf("[ERROR] Failed to write FileBrowser compose: %v", err) return } // Recreate container cmd := exec.Command("docker", "compose", "up", "-d", "--remove-orphans") cmd.Dir = filepath.Dir(composePath) if out, err := cmd.CombinedOutput(); err != nil { s.logger.Printf("[ERROR] Failed to recreate FileBrowser: %s — %v", string(out), err) } else { s.logger.Printf("[INFO] FileBrowser mounts synced — %d storage path(s)", len(paths)) } } // generateFileBrowserCompose returns a FileBrowser docker-compose.yml string // with the given domain and storage volume mount lines. func generateFileBrowserCompose(domain string, storageMounts []string) string { storageSection := "" if len(storageMounts) > 0 { storageSection = "\n # Storage paths (auto-generated by felhom-controller)\n" + strings.Join(storageMounts, "\n") } return fmt.Sprintf(`# FileBrowser Quantum — Infrastructure file manager # Domain: files.%s # Deployed by docker-setup.sh — managed by felhom-controller # WARNING: Volume mounts are auto-generated. Manual edits will be overwritten. services: filebrowser: image: gtstef/filebrowser:latest container_name: filebrowser restart: unless-stopped environment: - TZ=Europe/Budapest volumes: - filebrowser_data:/home/filebrowser/data%s networks: - traefik-public deploy: resources: limits: memory: 256M healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"] interval: 30s timeout: 5s retries: 3 start_period: 15s labels: - "traefik.enable=true" - "traefik.http.routers.filebrowser.rule=Host(`+"`"+`files.%s`+"`"+`)" - "traefik.http.routers.filebrowser.entrypoints=websecure" - "traefik.http.routers.filebrowser.tls=true" - "traefik.http.services.filebrowser.loadbalancer.server.port=80" - "traefik.docker.network=traefik-public" volumes: filebrowser_data: networks: traefik-public: external: true `, domain, storageSection, domain) }