diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index bd3cc7a..f2c08f7 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -895,10 +895,19 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound) } -func (s *Server) settingsData() map[string]interface{} { - data := s.baseData("settings", "Beállítások") +// 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{} { + data := s.baseData(page, title) data["CustomerID"] = s.cfg.Customer.ID data["CustomerDomain"] = s.cfg.Customer.Domain + return data +} + +// systemPageData builds the Rendszer subpage: read-only configuration, version/update, +// controller + server restart. +func (s *Server) systemPageData() map[string]interface{} { + data := s.settingsBaseData("settings", "Beállítások") data["GitRepoURL"] = s.cfg.Git.RepoURL data["GitSyncInterval"] = s.cfg.Git.SyncInterval data["BackupEnabled"] = s.cfg.Backup.Enabled @@ -928,15 +937,13 @@ func (s *Server) settingsData() map[string]interface{} { // to. Empty = none set by the operator. data["ControllerFloor"] = s.updater.GetFloor() } + return data +} - data["NotificationPrefs"] = s.settings.GetNotificationPrefs() - - // App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay - // path runs through the hub); the template hides the control otherwise. - appEmail := s.settings.GetAppEmail() - data["AppEmailEnabled"] = appEmail.Enabled - data["AppEmailFromName"] = appEmail.FromName - data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled() +// storagePageData builds the Tárhely page: physical drive registry, NAS shares, and the +// data the unified agent-enriched drive view needs. +func (s *Server) storagePageData() map[string]interface{} { + data := s.settingsBaseData("storage", "Tárhely") // Storage paths with display data storagePaths := s.settings.GetStoragePaths() @@ -983,6 +990,28 @@ func (s *Server) settingsData() map[string]interface{} { // NAS network storage (Part A2) — separate section with the agent's per-share health (ok/idle/ // unreachable/unknown). Distinct from the physical-drive list above; no drive lifecycle actions. data["NetworkStoragePaths"] = s.networkStorageItems(context.Background()) + return data +} + +// notificationsPageData builds the Értesítések subpage: notification prefs + app-email. +func (s *Server) notificationsPageData() map[string]interface{} { + data := s.settingsBaseData("settings-notifications", "Értesítések") + data["HubEnabled"] = s.cfg.Hub.Enabled + data["NotificationPrefs"] = s.settings.GetNotificationPrefs() + + // App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay + // path runs through the hub); the template hides the control otherwise. + appEmail := s.settings.GetAppEmail() + data["AppEmailEnabled"] = appEmail.Enabled + data["AppEmailFromName"] = appEmail.FromName + data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled() + return data +} + +// securityPageData builds the Biztonság és hozzáférés subpage: password, geo-restriction, +// emergency/recovery info. +func (s *Server) securityPageData() map[string]interface{} { + data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés") // Recovery info for emergency section data["RetrievalPassword"] = s.settings.GetRetrievalPassword() @@ -1016,11 +1045,30 @@ func (s *Server) settingsData() map[string]interface{} { }) } data["DeployedApps"] = deployedApps + return data +} +// settingsData merges every subpage builder — legacy glue kept ONLY while the monolithic +// settings.html exists (deleted with the D1 Part 2 template split). +func (s *Server) settingsData() map[string]interface{} { + data := s.systemPageData() + for _, m := range []map[string]interface{}{s.storagePageData(), s.notificationsPageData(), s.securityPageData()} { + for k, v := range m { + if k == "Page" || k == "Title" { + continue + } + data[k] = v + } + } return data } func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) { + s.executeTemplate(w, r, "settings", s.settingsData()) +} + +// storagePageHandler serves the Tárhely main-nav page (D1). Storage action flashes land here. +func (s *Server) storagePageHandler(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") @@ -1028,6 +1076,17 @@ func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "settings", data) } +// settingsNotificationsPageHandler serves GET /settings/notifications (the POST on the same +// path is the save handler — dispatch is split in the router). +func (s *Server) settingsNotificationsPageHandler(w http.ResponseWriter, r *http.Request) { + s.executeTemplate(w, r, "settings", s.settingsData()) +} + +// settingsSecurityPageHandler serves GET /settings/security. +func (s *Server) settingsSecurityPageHandler(w http.ResponseWriter, r *http.Request) { + s.executeTemplate(w, r, "settings", s.settingsData()) +} + func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() currentPassword := r.FormValue("current_password") @@ -1492,7 +1551,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label) go s.SyncFileBrowserMounts() - http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound) + http.Redirect(w, r, "/storage?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) { @@ -1538,7 +1597,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req s.logger.Printf("[INFO] [web] 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) + http.Redirect(w, r, "/storage?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) { @@ -1551,11 +1610,11 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re if err := s.settings.SetDefaultStoragePath(path); err != nil { s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err) - http.Redirect(w, r, "/settings", http.StatusFound) + http.Redirect(w, r, "/storage", http.StatusFound) return } s.logger.Printf("[INFO] [web] Default storage path set to %s", path) - http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound) + http.Redirect(w, r, "/storage?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) { @@ -1569,11 +1628,11 @@ func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *htt if err := s.settings.SetSchedulable(path, schedulable); err != nil { s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err) - http.Redirect(w, r, "/settings", http.StatusFound) + http.Redirect(w, r, "/storage", http.StatusFound) return } s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable) - http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound) + http.Redirect(w, r, "/storage?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) { @@ -1601,7 +1660,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ } s.logger.Printf("[INFO] [web] 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) + http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound) } // SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index eeeac10..965aa30 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -262,6 +262,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.monitoringHandler(w, r) case path == "/settings": s.settingsHandler(w, r) + case path == "/storage" && r.Method == http.MethodGet: + s.storagePageHandler(w, r) + case path == "/settings/notifications" && r.Method == http.MethodGet: + s.settingsNotificationsPageHandler(w, r) + case path == "/settings/security" && r.Method == http.MethodGet: + s.settingsSecurityPageHandler(w, r) case path == "/settings/password" && r.Method == http.MethodPost: s.settingsPasswordHandler(w, r) case path == "/settings/notifications" && r.Method == http.MethodPost: @@ -280,10 +286,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.settingsStorageSchedulableHandler(w, r) case path == "/settings/storage/label" && r.Method == http.MethodPost: s.settingsStorageLabelHandler(w, r) - case path == "/settings/storage/init" && r.Method == http.MethodGet: + case path == "/storage/init" && r.Method == http.MethodGet: s.storageWizardPageHandler(w, r, "storage_init") - case path == "/settings/storage/attach" && r.Method == http.MethodGet: + case path == "/storage/attach" && r.Method == http.MethodGet: s.storageWizardPageHandler(w, r, "storage_attach") + // D1: the wizard pages moved under /storage — permanent redirects keep old links working. + case path == "/settings/storage/init" && r.Method == http.MethodGet: + http.Redirect(w, r, "/storage/init", http.StatusMovedPermanently) + case path == "/settings/storage/attach" && r.Method == http.MethodGet: + http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently) case path == "/backup/restore" && r.Method == http.MethodPost: s.backupRestoreHandler(w, r) // Off-box (NAS) restic-SFTP backup (Part B) diff --git a/controller/internal/web/settings_split_test.go b/controller/internal/web/settings_split_test.go new file mode 100644 index 0000000..d3faded --- /dev/null +++ b/controller/internal/web/settings_split_test.go @@ -0,0 +1,149 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// testPageServer builds a Server complete enough to render full pages through ServeHTTP +// (templates loaded, settings + stack manager real, agent/hub absent). +func testPageServer(t *testing.T) *Server { + t.Helper() + lg := log.New(io.Discard, "", 0) + dir := t.TempDir() + cfg := &config.Config{} + cfg.Customer.ID = "test-customer" + cfg.Customer.Name = "Teszt Ügyfél" + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Stacks.ComposeCommand = "docker compose" // skip detection (not needed for page renders) + + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatalf("settings: %v", err) + } + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatalf("stacks manager: %v", err) + } + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} + s.loadTemplates() + return s +} + +func getPage(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + s.ServeHTTP(rec, req) + return rec +} + +// TestSettingsSplitPagesRender (D1 Scenario A skeleton): the four pages respond 200. +// Per-page unique-section markers are asserted once the template split lands (Part 2). +func TestSettingsSplitPagesRender(t *testing.T) { + s := testPageServer(t) + for _, path := range []string{"/settings", "/settings/notifications", "/settings/security", "/storage"} { + rec := getPage(t, s, path) + if rec.Code != 200 { + t.Errorf("GET %s = %d, want 200", path, rec.Code) + } + } +} + +// TestWizardRoutesMovedWith301 (D1 Scenario E): old wizard URLs permanently redirect. +func TestWizardRoutesMovedWith301(t *testing.T) { + s := testPageServer(t) + cases := map[string]string{ + "/settings/storage/init": "/storage/init", + "/settings/storage/attach": "/storage/attach", + } + for old, want := range cases { + rec := getPage(t, s, old) + if rec.Code != http.StatusMovedPermanently { + t.Errorf("GET %s = %d, want 301", old, rec.Code) + } + if loc := rec.Header().Get("Location"); loc != want { + t.Errorf("GET %s Location = %q, want %q", old, loc, want) + } + } + // and the new URLs render + for _, p := range []string{"/storage/init", "/storage/attach"} { + if rec := getPage(t, s, p); rec.Code != 200 { + t.Errorf("GET %s = %d, want 200", p, rec.Code) + } + } +} + +// TestStorageActionRedirectsToStorage (D1 Scenario D): storage POST successes land on +// /storage?storage_msg=..., and the flash renders there. +func TestStorageActionRedirectsToStorage(t *testing.T) { + s := testPageServer(t) + if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/test-drive", Label: "Teszt", Schedulable: true}); err != nil { + t.Fatalf("add path: %v", err) + } + + form := url.Values{"storage_path": {"/mnt/test-drive"}, "storage_label": {"Új Név"}} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/settings/storage/label", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.settingsStorageLabelHandler(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("label POST = %d, want 302", rec.Code) + } + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, "/storage?storage_msg=success") { + t.Errorf("Location = %q, want prefix /storage?storage_msg=success", loc) + } + + // The flash renders on /storage + rec2 := getPage(t, s, loc) + if rec2.Code != 200 { + t.Fatalf("GET %s = %d, want 200", loc, rec2.Code) + } + if !strings.Contains(rec2.Body.String(), "Megnevezés módosítva") { + t.Errorf("/storage did not render the storage flash message") + } +} + +// TestPasswordErrorRerendersSecurityPage (D1 Scenario D): a wrong current password +// re-renders the page carrying the password form with the inline error. +func TestPasswordErrorRerendersSecurityPage(t *testing.T) { + s := testPageServer(t) + // enable auth so the password form path is active + if err := s.settings.SetPasswordHash("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"); err != nil { // "password" + t.Fatalf("set hash: %v", err) + } + form := url.Values{ + "current_password": {"wrong-password"}, + "new_password": {"newpassword123"}, + "confirm_password": {"newpassword123"}, + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/settings/password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.settingsPasswordHandler(rec, req) + + if rec.Code != 200 { + t.Fatalf("password POST = %d, want 200 (inline error re-render)", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Hibás jelenlegi jelszó") { + t.Error("missing inline error 'Hibás jelenlegi jelszó'") + } + if !strings.Contains(body, "Jelszó módosítás") { + t.Error("re-rendered page lacks the password form section") + } +}