D1 Part 1: settings-split routes + per-page data builders
- server.go: GET /storage (Tárhely page), GET /settings/notifications (GET->page, POST->save dispatch on the same path), GET /settings/security; the enrollment wizards move to /storage/init + /storage/attach with 301s from the old /settings/storage/* URLs. - handlers.go: settingsData() decomposed into settingsBaseData + systemPageData / storagePageData / notificationsPageData / securityPageData; the legacy merge remains only while the monolithic settings.html exists (Part 2 deletes it). All five storage action redirects (add/remove/default/schedulable/label) now land on /storage?storage_msg=... (incl. the two error-branch redirects). - Every page keeps rendering the full legacy template in this commit — the site stays functional; the split lands in Part 2. - Tests: four pages 200, wizard 301s + new URLs render, storage-label redirect Location prefix + flash renders on /storage, wrong-password inline re-render. Red-proven vs pre-split code (Location was /settings?..., no 301s).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user