package http import ( "encoding/json" "errors" "log/slog" stdhttp "net/http" "strconv" "strings" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // ui_repo.go — HTML form-driven repo-tab handlers (connection, // bandwidth caps, maintenance cadences, danger-zone re-init). Splits // the page into three independent forms so saving one section // doesn't disturb the others. // // GET /hosts/{id}/repo — render // POST /hosts/{id}/repo/credentials — connection // POST /hosts/{id}/repo/bandwidth — host-wide bw caps // POST /hosts/{id}/repo/maintenance — forget/prune/check cadences type hostRepoPage struct { hostChromeData // Connection (redacted view) RepoURL string RepoUsername string HasPassword bool // Bandwidth (form values, blank means "no cap") BandwidthUp string BandwidthDown string // Maintenance row Maintenance store.HostRepoMaintenance // Snapshots-by-tag — map[group_name]count, plus an "untagged" row. SnapshotsByTag map[string]int UntaggedSnapshots int GroupNames []string // ordered, for stable rendering // Inline form-error banners. Empty when no error for that section. CredentialsError string BandwidthError string MaintenanceError string // Highlight which form was just submitted, for the success-state // border (subtle UX nicety; empty = no recent save). SavedSection string } // loadHostRepoPage builds the read-only side of the page state. The // per-form save handlers re-call this and overlay any banner / saved // markers before rendering. func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRepoPage, error) { p := &hostRepoPage{ hostChromeData: s.loadHostChrome(r, host, "repo", "repo"), } // Credentials (redacted). enc, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID) switch { case err == nil: plain, derr := s.deps.AEAD.Decrypt(enc, []byte("host:"+host.ID)) if derr == nil { var blob repoCredsBlob if jerr := json.Unmarshal(plain, &blob); jerr == nil { p.RepoURL = blob.RepoURL p.RepoUsername = blob.RepoUsername p.HasPassword = blob.RepoPassword != "" } } case errors.Is(err, store.ErrNotFound): // no creds yet — leave fields empty default: return nil, err } // Bandwidth. if host.BandwidthUpKBps != nil { p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps) } if host.BandwidthDownKBps != nil { p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps) } // Maintenance — auto-seed defaults if missing. m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID) if err != nil && errors.Is(err, store.ErrNotFound) { if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); seedErr != nil { return nil, seedErr } m, err = s.deps.Store.GetRepoMaintenance(r.Context(), host.ID) } if err != nil { return nil, err } p.Maintenance = *m // Snapshot counts by tag — used for the right-rail breakdown. groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID) if err == nil { groupNameSet := make(map[string]struct{}, len(groups)) for _, g := range groups { p.GroupNames = append(p.GroupNames, g.Name) groupNameSet[g.Name] = struct{}{} } if snaps, serr := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID); serr == nil { p.SnapshotsByTag = make(map[string]int, len(groups)) for _, sn := range snaps { matched := false for _, t := range sn.Tags { if _, ok := groupNameSet[t]; ok { p.SnapshotsByTag[t]++ matched = true } } if !matched { p.UntaggedSnapshots++ } } } } return p, nil } func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } page, err := s.loadHostRepoPage(r, *host) if err != nil { slog.Error("ui repo: load page", "host_id", host.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } page.SavedSection = r.URL.Query().Get("saved") view := s.baseView(u) view.Title = host.Name + " repo · restic-manager" view.Page = *page if err := s.deps.UI.Render(w, "host_repo", view); err != nil { slog.Error("ui: render host_repo", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // renderRepoFormError loads the page state, overlays the section's // error banner, and renders with a 422. Save-success goes through a // 303 redirect with `?saved=
` instead, so this path is for // validation failures only. func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, credErr, bwErr, mntErr string) { page, err := s.loadHostRepoPage(r, *host) if err != nil { slog.Error("ui repo: reload after save", "host_id", host.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } page.CredentialsError = credErr page.BandwidthError = bwErr page.MaintenanceError = mntErr view := s.baseView(u) view.Title = host.Name + " repo · restic-manager" view.Page = *page w.WriteHeader(stdhttp.StatusUnprocessableEntity) if err := s.deps.UI.Render(w, "host_repo", view); err != nil { slog.Error("ui: render host_repo", "err", err) } } // handleUIRepoCredentialsSave updates the host's stored repo URL, // username, and (optionally) password. Empty password means "leave // the existing one alone" — passwords are never round-tripped to the // browser, so a blank field is the only way an operator can save the // other fields without re-typing the password. func (s *Server) handleUIRepoCredentialsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } repoURL := strings.TrimSpace(r.PostForm.Get("repo_url")) repoUser := strings.TrimSpace(r.PostForm.Get("repo_username")) repoPass := r.PostForm.Get("repo_password") // do NOT trim — operators may use trailing space deliberately if repoURL == "" { s.renderRepoPage(w, r, u, host, "Repo URL is required.", "", "") return } // Merge with existing blob — same semantics as the JSON PUT. existing := repoCredsBlob{} if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID); err == nil { if plain, derr := s.deps.AEAD.Decrypt(cur, []byte("host:"+host.ID)); derr == nil { _ = json.Unmarshal(plain, &existing) } } existing.RepoURL = repoURL existing.RepoUsername = repoUser if repoPass != "" { existing.RepoPassword = repoPass } if existing.RepoPassword == "" { s.renderRepoPage(w, r, u, host, "No password on file yet — set one before saving the URL/username.", "", "") return } enc, err := s.encryptRepoCreds(existing, []byte("host:"+host.ID)) if err != nil { slog.Error("ui repo creds: encrypt", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, enc); err != nil { slog.Error("ui repo creds: persist", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) { _ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing) } stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=credentials", stdhttp.StatusSeeOther) } // handleUIRepoBandwidthSave updates the host's upload/download caps. // Empty input → nil pointer → no cap. Negative → error. func (s *Server) handleUIRepoBandwidthSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } up, upErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_up")) down, downErr := parseOptionalNonNegInt(r.PostForm.Get("bandwidth_down")) if upErr != nil || downErr != nil { s.renderRepoPage(w, r, u, host, "", "Bandwidth caps must be non-negative whole numbers (or blank for no cap).", "") return } if err := s.deps.Store.SetHostBandwidth(r.Context(), host.ID, up, down); err != nil { slog.Error("ui repo bandwidth: persist", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=bandwidth", stdhttp.StatusSeeOther) } // handleUIRepoMaintenanceSave updates the forget/prune/check // cadences in one go. Cron expressions parsed with the same parser // the agent + REST handler use. func (s *Server) handleUIRepoMaintenanceSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } forgetCron := strings.TrimSpace(r.PostForm.Get("forget_cron")) pruneCron := strings.TrimSpace(r.PostForm.Get("prune_cron")) checkCron := strings.TrimSpace(r.PostForm.Get("check_cron")) subsetStr := strings.TrimSpace(r.PostForm.Get("check_subset_pct")) for label, expr := range map[string]string{ "forget": forgetCron, "prune": pruneCron, "check": checkCron, } { if expr == "" { s.renderRepoPage(w, r, u, host, "", "", label+" cadence is required.") return } if _, err := cronParser.Parse(expr); err != nil { s.renderRepoPage(w, r, u, host, "", "", label+" cadence didn't parse: "+err.Error()) return } } subset, err := strconv.Atoi(subsetStr) if err != nil || subset < 0 || subset > 100 { s.renderRepoPage(w, r, u, host, "", "", "check subset % must be between 0 and 100.") return } if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), host.ID); err != nil { slog.Error("ui repo maintenance: seed", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } m := store.HostRepoMaintenance{ HostID: host.ID, ForgetCron: forgetCron, ForgetEnabled: r.PostForm.Get("forget_enabled") == "1", PruneCron: pruneCron, PruneEnabled: r.PostForm.Get("prune_enabled") == "1", CheckCron: checkCron, CheckEnabled: r.PostForm.Get("check_enabled") == "1", CheckSubsetPct: subset, } if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil { slog.Error("ui repo maintenance: persist", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=maintenance", stdhttp.StatusSeeOther) } // parseOptionalNonNegInt returns (nil, nil) for an empty string, or // (*int, nil) for a non-negative integer. Negative or non-numeric → // error. Used for bandwidth caps where blank means "no limit". func parseOptionalNonNegInt(s string) (*int, error) { s = strings.TrimSpace(s) if s == "" { return nil, nil } n, err := strconv.Atoi(s) if err != nil || n < 0 { return nil, errors.New("invalid") } return &n, nil }