package http import ( "encoding/json" "errors" "log/slog" stdhttp "net/http" "strconv" "strings" "time" "github.com/oklog/ulid/v2" "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 // POST /hosts/{id}/admin-credentials — admin (prune) creds // POST /hosts/{id}/admin-credentials/delete — clear admin creds // repoStatsView is a flat, pre-dereferenced projection of // store.HostRepoStats for use in templates. Nil pointer fields are // collapsed to zero/false and accompanied by a Has* sentinel so the // template can distinguish "zero" from "not yet known." type repoStatsView struct { HasTotalSize bool TotalSizeBytes int64 HasRawSize bool RawSizeBytes int64 HasLastCheck bool LastCheckAt time.Time LastCheckAgo string LastCheckStatus string LockPresent bool HasLastPrune bool LastPruneAt time.Time LastPruneAgo string } type hostRepoPage struct { hostChromeData // Connection (redacted view) RepoURL string RepoUsername string HasPassword bool // Admin credentials (optional, prune-only — separate slot). AdminURL string AdminUsername string HasAdminPassword bool // Bandwidth (form values, blank means "no cap") BandwidthUp string BandwidthDown string // Maintenance row Maintenance store.HostRepoMaintenance // Online mirrors Hub.Connected so Run-now button disabled state is // accurate at render time. Online bool // StatsView is a pre-dereferenced projection of host_repo_stats. // Nil when no row exists yet (fresh hosts). StatsView *repoStatsView // Snapshots-by-tag — map[group_name]count, plus an "untagged" row. SnapshotsByTag map[string]int UntaggedSnapshots int GroupNames []string // ordered, for stable rendering // Host-default hooks (decrypted plaintext for round-trip in form). HostPreHook string HostPostHook string // Inline form-error banners. Empty when no error for that section. CredentialsError string AdminCredsError string BandwidthError string MaintenanceError string HooksError 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, store.CredKindRepo) 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 } // Admin credentials (optional — prune-only slot). adminEnc, aerr := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin) switch { case aerr == nil: plain, derr := s.deps.AEAD.Decrypt(adminEnc, []byte("host:"+host.ID+":admin")) if derr == nil { var blob repoCredsBlob if jerr := json.Unmarshal(plain, &blob); jerr == nil { p.AdminURL = blob.RepoURL p.AdminUsername = blob.RepoUsername p.HasAdminPassword = blob.RepoPassword != "" } } case errors.Is(aerr, store.ErrNotFound): // admin slot not configured — fine default: return nil, aerr } // Online status. if s.deps.Hub != nil { p.Online = s.deps.Hub.Connected(host.ID) } // Repo stats (tolerate ErrNotFound — fresh hosts have no row yet). if stats, serr := s.deps.Store.GetHostRepoStats(r.Context(), host.ID); serr == nil { sv := &repoStatsView{} if stats.TotalSizeBytes != nil { sv.HasTotalSize = true sv.TotalSizeBytes = *stats.TotalSizeBytes } if stats.RawSizeBytes != nil { sv.HasRawSize = true sv.RawSizeBytes = *stats.RawSizeBytes } if stats.LastCheckAt != nil { sv.HasLastCheck = true sv.LastCheckAt = *stats.LastCheckAt sv.LastCheckAgo = relTimeAgo(*stats.LastCheckAt) } sv.LastCheckStatus = stats.LastCheckStatus if stats.LockPresent != nil { sv.LockPresent = *stats.LockPresent } if stats.LastPruneAt != nil { sv.HasLastPrune = true sv.LastPruneAt = *stats.LastPruneAt sv.LastPruneAgo = relTimeAgo(*stats.LastPruneAt) } p.StatsView = sv } else if !errors.Is(serr, store.ErrNotFound) { return nil, serr } // Bandwidth. if host.BandwidthUpKBps != nil { p.BandwidthUp = strconv.Itoa(*host.BandwidthUpKBps) } if host.BandwidthDownKBps != nil { p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps) } // Host-default hooks (decrypt for round-trip in the form). p.HostPreHook = s.decryptHookOrFallback("", host.PreHookDefault, host.ID, "pre") p.HostPostHook = s.decryptHookOrFallback("", host.PostHookDefault, host.ID, "post") // 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(r, 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) } } // renderRepoPage loads the page state, overlays section error banners, // 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, adminErr, 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.AdminCredsError = adminErr page.BandwidthError = bwErr page.MaintenanceError = mntErr view := s.baseView(r, 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, store.CredKindRepo); 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, store.CredKindRepo, enc); err != nil { slog.Error("ui repo creds: persist", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } // NS-03: clear repo_status — the new creds may reach a different // repo or fix an auth typo, so any prior probe outcome is stale. if err := s.deps.Store.SetHostRepoStatus(r.Context(), host.ID, "unknown", ""); err != nil { slog.Warn("ui repo creds: reset repo_status", "host_id", host.ID, "err", err) } if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) { _ = s.pushRepoCredsToAgent(r.Context(), host.ID, existing) // NS-03: probe the new creds immediately — surface bad // password / wrong URL on the host detail page rather than at // the next scheduled job. if err := s.dispatchInitJob(r.Context(), host.ID, "user", &u.ID); err != nil { slog.Warn("ui repo creds: dispatch init", "host_id", host.ID, "err", err) } } 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 } // relTimeAgo returns a short human-readable relative-time string like // "5m ago", "3h ago", "2d ago" for use in stats panels. Does not use // the template funcMap so it can be called from Go directly. func relTimeAgo(t time.Time) string { d := time.Since(t) if d < 0 { d = 0 } switch { case d < time.Minute: return "just now" case d < time.Hour: return strconv.Itoa(int(d.Minutes())) + "m ago" case d < 24*time.Hour: return strconv.Itoa(int(d.Hours())) + "h ago" case d < 30*24*time.Hour: return strconv.Itoa(int(d.Hours()/24)) + "d ago" default: return t.Format("2006-01-02") } } // handleUIAdminCredentialsSave handles the HTML form POST to // /hosts/{id}/admin-credentials. Mirrors handleUIRepoCredentialsSave // but operates on the admin slot (store.CredKindAdmin, AAD "host::admin"). // Re-renders the page with an inline error on validation failure; // redirects with ?saved=admin_credentials on success. func (s *Server) handleUIAdminCredentialsSave(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") // All blank → no-op save (operator hit Save without filling anything). // We treat this as harmless — they may have wanted to clear via the // Clear button instead. Only validate if they've started filling fields. if repoURL == "" && repoUser == "" && repoPass == "" { stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo", stdhttp.StatusSeeOther) return } aad := []byte("host:" + host.ID + ":admin") // Merge with the existing admin row, if any. existing := repoCredsBlob{} if cur, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindAdmin); err == nil { if plain, derr := s.deps.AEAD.Decrypt(cur, aad); derr == nil { _ = json.Unmarshal(plain, &existing) } } existing.RepoURL = repoURL existing.RepoUsername = repoUser if repoPass != "" { existing.RepoPassword = repoPass } if existing.RepoURL == "" { s.renderRepoPage(w, r, u, host, "", "Repo URL is required.", "", "") return } 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, aad) if err != nil { slog.Error("ui admin creds: encrypt", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err := s.deps.Store.SetHostCredentials(r.Context(), host.ID, store.CredKindAdmin, enc); err != nil { slog.Error("ui admin creds: persist", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", Action: "host.admin_credentials_set", TargetKind: ptr("host"), TargetID: &host.ID, TS: nowUTC(), }) if s.deps.Hub != nil && s.deps.Hub.Connected(host.ID) { if perr := s.pushAdminCredsToAgent(r.Context(), host.ID); perr != nil { slog.Warn("ui admin creds: push to agent", "host_id", host.ID, "err", perr) } } stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther) } // handleUIAdminCredentialsDelete handles the HTML form POST to // /hosts/{id}/admin-credentials/delete. Removes the admin slot and // redirects back to the repo page. Treats "not found" as success // (idempotent delete from the operator's point of view). func (s *Server) handleUIAdminCredentialsDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } host, ok := s.loadHostForUI(w, r) if !ok { return } err := s.deps.Store.DeleteHostCredentials(r.Context(), host.ID, store.CredKindAdmin) if err != nil && !errors.Is(err, store.ErrNotFound) { slog.Error("ui admin creds: delete", "host_id", host.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err == nil { _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", Action: "host.admin_credentials_deleted", TargetKind: ptr("host"), TargetID: &host.ID, TS: nowUTC(), }) } stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=admin_credentials", stdhttp.StatusSeeOther) }