From 27aeb415f47be5c4907fe21c6ed3d4921815e7bb Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 5 Jul 2026 13:20:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20C2=20Parts=202+3=20=E2=80=94=20POS?= =?UTF-8?q?T=20/backup/tier2/restore=20+=20"F=C3=A1jlok=20vissza=C3=A1ll?= =?UTF-8?q?=C3=ADt=C3=A1sa"=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Endpoint next to /backup/restore; handler mirrors backupRestoreHandler (ParseForm → validStackName → backupMgr guard → WARN with RemoteAddr → RestoreTier2Files → flash). Flash strings: ": N fájl visszaállítva a másodlagos másolatból." / "Nincs hiányzó fájl — minden fájl megvan a helyén." / "Fájl-visszaállítás sikertelen: " (refusals carry the Hungarian reasons from the engine). - backups.html: the button on the healthy Tier-2 layer row only (the Tier2Configured branch already excludes disconnected/inactive; additionally gated on Tier2LastRun), inline POST form with CSRF + confirm dialog naming the additive-only semantics and the last-copy timestamp. Template gates (id + emoji) green. - Handler guard test (C6): traversal/empty → exact Hungarian flash, no work started (nil backupMgr would panic if reached). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- controller/internal/web/handlers.go | 38 ++++++++++++++ controller/internal/web/server.go | 3 ++ .../internal/web/templates/backups.html | 6 +++ .../web/tier2_restore_handler_test.go | 50 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 controller/internal/web/tier2_restore_handler_test.go diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index b83d814..e07d1cf 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -896,6 +896,44 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound) } +// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its +// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is +// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler. +func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + stackName := r.FormValue("stack_name") + + if stackName == "" { + http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) + return + } + // Same F2-defense as the unit restore: a stack name is a single segment, never a path. + if !validStackName(stackName) { + s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr) + http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", 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] [web] Tier-2 file restore requested: stack=%s from %s", stackName, r.RemoteAddr) + + n, err := s.backupMgr.RestoreTier2Files(stackName) + if err != nil { + s.logger.Printf("[ERROR] [web] Tier-2 file restore failed: %v", err) + http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Fájl-visszaállítás sikertelen: "+err.Error()), http.StatusFound) + return + } + + msg := "Nincs hiányzó fájl — minden fájl megvan a helyén." + if n > 0 { + msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n) + } + http.Redirect(w, r, "/backups?flash="+url.QueryEscape(msg), http.StatusFound) +} + // settingsBaseData is the shared identity block used by every settings-family subpage // (D1 split: /settings, /settings/notifications, /settings/security, /storage). func (s *Server) settingsBaseData(page, title string) map[string]interface{} { diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 07ccc8a..571e93a 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -299,6 +299,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently) case path == "/backup/restore" && r.Method == http.MethodPost: s.backupRestoreHandler(w, r) + // C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files) + case path == "/backup/tier2/restore" && r.Method == http.MethodPost: + s.backupTier2RestoreHandler(w, r) // Off-box (NAS) restic-SFTP backup (Part B) case path == "/backup/offbox/config" && r.Method == http.MethodPost: s.offboxConfigHandler(w, r) diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index e915d0f..8ebbfdb 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -436,6 +436,12 @@ {{.BackupContents}}
+ {{if .Tier2LastRun}} +
{{$.CSRFField}} + + +
+ {{end}} Beállítás
{{else}} diff --git a/controller/internal/web/tier2_restore_handler_test.go b/controller/internal/web/tier2_restore_handler_test.go new file mode 100644 index 0000000..e41231f --- /dev/null +++ b/controller/internal/web/tier2_restore_handler_test.go @@ -0,0 +1,50 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// postTier2Restore drives the handler with a form body, as the UI's inline form does. +func postTier2Restore(t *testing.T, s *Server, stackName string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"stack_name": {stackName}} + req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + s.backupTier2RestoreHandler(rec, req) + return rec +} + +// TestTier2RestoreHandler_Guards proves Scenario C6 + the missing-param guard: traversal and empty +// names are rejected with the exact Hungarian flash BEFORE any restore work (backupMgr is nil here — +// reaching it would panic, so a pass also proves no work started). +func TestTier2RestoreHandler_Guards(t *testing.T) { + s := &Server{logger: log.New(io.Discard, "", 0)} // backupMgr nil on purpose + + for name, want := range map[string]string{ + "../../etc": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", // Érvénytelen alkalmazásnév + "a/b": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", + "": "Hi%C3%A1nyz%C3%B3+param%C3%A9terek", // Hiányzó paraméterek + } { + rec := postTier2Restore(t, s, name) + if rec.Code != http.StatusFound { + t.Errorf("%q: status = %d, want 302", name, rec.Code) + continue + } + if loc := rec.Header().Get("Location"); !strings.Contains(loc, want) { + t.Errorf("%q: redirect = %q, want flash %q", name, loc, want) + } + } + + // Valid name but no backup manager → the not-configured flash (still no panic, no work). + rec := postTier2Restore(t, s, "nextcloud") + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") { + t.Errorf("nil backupMgr: redirect = %q", loc) + } +}