package web import ( "net/http" "net/url" ) // Per-app Tier-2 (off-drive copy) config panel — item 4. // // The "2. mentés" row on the backup page used to link its "Beállítás" button at the app's deploy // page, which has no backup-location setting (a dead end). This is the real surface: it shows the // current/auto off-drive target + last-run status, and lets the customer pin a different registered // drive or turn Tier 2 off. It is ALWAYS shown — even when only the internal SSD qualifies, or the // app's data lives on the rootfs (already in PBS) — with honest context rather than a hidden control. // // Routes (wired in server.go, behind RequireAuth + CsrfProtect): // GET /stacks/{name}/backup → tier2ConfigPageHandler // POST /stacks/{name}/backup → tier2ConfigSaveHandler func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request, name string) { stack, ok := s.stackMgr.GetStack(name) if !ok { http.NotFound(w, r) return } if s.backupMgr == nil { http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable) return } info := s.backupMgr.Tier2Info(name) data := s.baseData("backups", "2. mentés beállítása — "+stack.Meta.DisplayName) data["StackName"] = name data["DisplayName"] = stack.Meta.DisplayName data["Tier2"] = info if !info.IsHDDApp { // R-499: the sentence about a system-disk app must say where THIS box's whole-system backup // goes. It used to promise „már szerepelnek a teljes rendszermentésben (PBS)" on every box. data["SystemBackup"] = systemBackupFact(s.resolveBackupTargetState(r.Context())) } if flash := s.flashFrom(r, "flash"); flash != "" { data["Flash"] = flash } if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" { data["FlashError"] = flashErr } s.executeTemplate(w, r, "tier2_config", data) } func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request, name string) { if _, ok := s.stackMgr.GetStack(name); !ok { http.NotFound(w, r) return } if s.backupMgr == nil { http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable) return } _ = r.ParseForm() // "enabled" checkbox: present → Tier 2 on; absent → off (UserDisabled = !enabled). enabled := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true" target := r.FormValue("target") // "" = automatic; otherwise a registered drive path // Validate the chosen target against the eligible alternatives (defence-in-depth: the runner // also re-validates off-disk at run time, but reject a bogus path here for a clean message). if target != "" { valid := false for _, opt := range s.backupMgr.Tier2Info(name).Alternatives { if opt.Path == target { valid = true break } } if !valid { s.redirectTier2(w, r, name, "", "flash.tier2.target_invalid") return } } if err := s.settings.SetTier2Preference(name, !enabled, target); err != nil { s.logger.Printf("[ERROR] [web] save Tier 2 preference for %s: %v", name, err) s.redirectTier2(w, r, name, "", "flash.tier2.save_failed") return } s.logger.Printf("[INFO] [web] Tier 2 preference saved for %s: enabled=%v target=%q", name, enabled, target) // Apply immediately when enabled for an HDD app so the customer sees the result on return. if enabled && s.backupMgr.Tier2Info(name).IsHDDApp { go func() { if err := s.backupMgr.RunTier2(name); err != nil { s.logger.Printf("[WARN] [web] immediate Tier 2 run for %s failed: %v", name, err) } }() } s.redirectTier2(w, r, name, "flash.tier2.saved", "") } // appEmailToggleHandler flips the per-app email toggle (only for apps with an smtp_mapping) // and recreates the stack so the SMTP env injection takes effect. Redirects back to the // app's config page with a flash. func (s *Server) appEmailToggleHandler(w http.ResponseWriter, r *http.Request, name string) { if _, ok := s.stackMgr.GetStack(name); !ok { http.NotFound(w, r) return } _ = r.ParseForm() enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true" dest := "/stacks/" + url.PathEscape(name) + "/deploy" if err := s.stackMgr.SetAppEmailEnabled(name, enabled); err != nil { s.logger.Printf("[ERROR] [web] app-email toggle for %s: %v", name, err) http.Redirect(w, r, dest+"?flash_error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } s.logger.Printf("[INFO] [web] App-email for %s set to %v", name, enabled) msg := "flash.tier2.app_email_off" if enabled { msg = "flash.tier2.app_email_on" } http.Redirect(w, r, dest+"?"+flashQuery("flash", msg), http.StatusSeeOther) } // redirectTier2 sends the customer back to the panel with a flash message. func (s *Server) redirectTier2(w http.ResponseWriter, r *http.Request, name, flash, flashErr string) { dest := "/stacks/" + url.PathEscape(name) + "/backup" q := url.Values{} if flash != "" { q.Set("flash", flash) } if flashErr != "" { q.Set("flash_error", flashErr) } if e := q.Encode(); e != "" { dest += "?" + e } http.Redirect(w, r, dest, http.StatusSeeOther) } // systemBackupFact reduces the whole-system backup's target state to the one fact the Tier-2 page // states about an app on the system disk (R-499). The page used to tell every such app that its data // was „already in the full system backup (PBS)" and there was nothing to do — measured false on a box // whose only whole-system copy sat on the same disk (2026-09-14). Four states, four sentences: // // protected — the whole-system backup goes to a drive of its own // same_disk — it goes to the system disk itself: protects against bad files, not a dead disk // absent — its drive is configured and gone: no fresh whole-system backup is being made // unknown — the agent could not be asked: say so, promise nothing // // Pinned by TestR499_* (the mapping and one render per branch). func systemBackupFact(st BackupTargetState) string { switch { case !st.Known: return "unknown" case st.TargetAbsent: return "absent" case st.Degraded: return "same_disk" default: return "protected" } }