package http import ( "context" "errors" "log/slog" stdhttp "net/http" "sort" "strings" "time" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // ui_restore.go — restore wizard backend (P3-01). // // GET /hosts/{id}/restore wizard step 1 (snapshot picker) // GET /hosts/{id}/snapshots/{sid}/restore wizard with snapshot pre-selected // GET /hosts/{id}/restore/tree HTMX partial: one tree node + children // POST /hosts/{id}/restore dispatch the restore job // hostRestorePage is the model for the wizard template. type hostRestorePage struct { hostChromeData // Snapshot picker rows; rendered by the template into the step-1 // table. Limited to most-recent N (the operator can refine on // snapshot ID if they need an older one — out of scope for v1). Snapshots []store.Snapshot // Selected is non-nil iff a snapshot has been chosen — either via // the deep-link path /hosts/{id}/snapshots/{sid}/restore or by a // previous form submission that the wizard re-rendered. Selected *store.Snapshot // Default target dir — surfaced in the step-3 radio card. DefaultTargetDir string // Online mirrors Hub.Connected so the dispatch button can be // disabled at render time when the agent is offline. Online bool // Error is shown as a banner above the wizard. Re-render-friendly: // the operator's snapshot/path/target choices survive the round-trip. Error string // Form fields preserved on validation re-render. The template // reads these to pre-tick checkboxes etc; the names match the // POST form keys. FormPaths []string // "/etc/nginx/sites-available/alfa.conf" FormInPlace bool FormTargetDir string FormConfirmHN string // typed-confirm input value } // handleUIRestoreGet renders the wizard. URL variants: // - /hosts/{id}/restore — step 1 = pick snapshot // - /hosts/{id}/snapshots/{sid}/restore — snapshot pre-selected func (s *Server) handleUIRestoreGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } hostID := chi.URLParam(r, "id") host, err := s.deps.Store.GetHost(r.Context(), hostID) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } slog.Error("ui restore: get host", "host_id", hostID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } page := hostRestorePage{ hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"), DefaultTargetDir: defaultRestoreTargetDir(), Online: s.deps.Hub.Connected(host.ID), } snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID) if err != nil { slog.Error("ui restore: list snapshots", "host_id", hostID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if len(snaps) > 100 { snaps = snaps[:100] } page.Snapshots = snaps // Snapshot deep-link variant — if the URL carries a sid, prefill it. if sid := chi.URLParam(r, "sid"); sid != "" { for i := range snaps { if snaps[i].ID == sid || snaps[i].ShortID == sid { p := snaps[i] page.Selected = &p break } } } view := s.baseView(r, u) view.Title = "Restore · " + host.Name view.Page = page if err := s.deps.UI.Render(w, "host_restore", view); err != nil { slog.Error("ui restore: render", "err", err) } } // handleUIRestorePost validates the form and dispatches the restore // job. On validation error re-renders the wizard with the error // banner + the operator's input intact. func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } hostID := chi.URLParam(r, "id") host, err := s.deps.Store.GetHost(r.Context(), hostID) if err != nil { stdhttp.NotFound(w, r) return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad form", stdhttp.StatusBadRequest) return } snapshotID := strings.TrimSpace(r.PostForm.Get("snapshot_id")) paths := r.PostForm["paths"] // multiple checkbox values inPlace := r.PostForm.Get("target_mode") == "in_place" targetDir := strings.TrimSpace(r.PostForm.Get("target_dir")) confirmHN := strings.TrimSpace(r.PostForm.Get("confirm_hostname")) rerender := func(errMsg string, status int) { page := hostRestorePage{ hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"), DefaultTargetDir: defaultRestoreTargetDir(), Online: s.deps.Hub.Connected(host.ID), Error: errMsg, FormPaths: paths, FormInPlace: inPlace, FormTargetDir: targetDir, FormConfirmHN: confirmHN, } snaps, _ := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID) if len(snaps) > 100 { snaps = snaps[:100] } page.Snapshots = snaps for i := range snaps { if snaps[i].ID == snapshotID || snaps[i].ShortID == snapshotID { ss := snaps[i] page.Selected = &ss break } } view := s.baseView(r, u) view.Title = "Restore · " + host.Name view.Page = page w.WriteHeader(status) _ = s.deps.UI.Render(w, "host_restore", view) } if snapshotID == "" { rerender("Pick a snapshot first.", stdhttp.StatusUnprocessableEntity) return } cleanPaths := make([]string, 0, len(paths)) for _, p := range paths { p = strings.TrimSpace(p) if p == "" { continue } if !strings.HasPrefix(p, "/") { rerender("Paths must be absolute (start with /).", stdhttp.StatusUnprocessableEntity) return } cleanPaths = append(cleanPaths, p) } if len(cleanPaths) == 0 { rerender("Pick at least one file or directory to restore.", stdhttp.StatusUnprocessableEntity) return } if inPlace { if confirmHN != host.Name { rerender("Type the host name exactly to confirm an in-place (overwrite) restore.", stdhttp.StatusUnprocessableEntity) return } } else { // New-directory mode: trust the operator's chosen target. // Empty falls back to the default. Validate it's either // absolute or starts with $HOME / ~/ (the agent expands // these at run time). if targetDir == "" { targetDir = defaultRestoreTargetDir() } if !looksLikeRestoreTarget(targetDir) { rerender("Target must be an absolute path, or start with $HOME or ~/.", stdhttp.StatusUnprocessableEntity) return } } if !s.deps.Hub.Connected(host.ID) { rerender("Agent is offline. Try again when it reconnects.", stdhttp.StatusServiceUnavailable) return } // Build a new job id up-front so we can substitute it into the // new-directory target path. The agent will additionally expand // $HOME / ~/ before invoking restic. jobID := ulid.Make().String() finalTarget := "" if !inPlace { finalTarget = strings.ReplaceAll(targetDir, "", jobID) } now := time.Now().UTC() if err := s.deps.Store.CreateJob(r.Context(), store.Job{ ID: jobID, HostID: host.ID, Kind: string(api.JobRestore), ActorKind: "user", ActorID: &u.ID, CreatedAt: now, }); err != nil { slog.Error("ui restore: create job", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } payload := api.CommandRunPayload{ JobID: jobID, Kind: api.JobRestore, Restore: &api.RestorePayload{ SnapshotID: snapshotID, Paths: cleanPaths, InPlace: inPlace, TargetDir: finalTarget, }, } env, err := api.Marshal(api.MsgCommandRun, jobID, payload) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err := s.deps.Hub.Send(r.Context(), host.ID, env); err != nil { slog.Warn("ui restore: dispatch failed", "err", err) rerender("Couldn't deliver the restore command (agent went offline).", stdhttp.StatusServiceUnavailable) return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", Action: "host.restore", TargetKind: ptr("host"), TargetID: &host.ID, TS: now, }) // HTMX redirect (or vanilla redirect) to the live job log. jobURL := "/jobs/" + jobID if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", jobURL) w.WriteHeader(stdhttp.StatusNoContent) return } stdhttp.Redirect(w, r, jobURL, stdhttp.StatusSeeOther) } // hostRestoreTreePage is the data shape for the tree-node HTMX partial. type hostRestoreTreePage struct { HostID string SnapshotID string Path string Children []treeChildView Error string } // treeChildView is one row of the tree (a direct child of Path). type treeChildView struct { Name string Type string // dir | file | symlink Path string // full path, used in the checkbox value Size int64 IsDir bool } // handleUIRestoreTree is the HTMX-served partial that loads one // directory's children. Called when the operator clicks an expand // chevron in the wizard's tree browser. Caches via fetchTreeWithCache. func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } hostID := chi.URLParam(r, "id") host, err := s.deps.Store.GetHost(r.Context(), hostID) if err != nil { stdhttp.NotFound(w, r) return } q := r.URL.Query() snapshotID := strings.TrimSpace(q.Get("snapshot")) pathArg := strings.TrimSpace(q.Get("path")) if pathArg == "" { pathArg = "/" } if snapshotID == "" { stdhttp.Error(w, "snapshot required", stdhttp.StatusBadRequest) return } if !s.deps.Hub.Connected(host.ID) { // Render the partial with an error message rather than 503ing // — the wizard renders the error inline next to the failed node. page := hostRestoreTreePage{ HostID: host.ID, SnapshotID: snapshotID, Path: pathArg, Error: "agent offline", } view := s.baseView(r, u) view.Page = page _ = s.deps.UI.RenderPartial(w, "tree_node", view) return } sessionID := sessionIDFromCookie(r) ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second) defer cancel() result, err := s.fetchTreeWithCache(ctx, sessionID, host.ID, snapshotID, pathArg) if err != nil { page := hostRestoreTreePage{ HostID: host.ID, SnapshotID: snapshotID, Path: pathArg, Error: err.Error(), } view := s.baseView(r, u) view.Page = page _ = s.deps.UI.RenderPartial(w, "tree_node", view) return } if result.Error != "" { page := hostRestoreTreePage{ HostID: host.ID, SnapshotID: snapshotID, Path: pathArg, Error: result.Error, } view := s.baseView(r, u) view.Page = page _ = s.deps.UI.RenderPartial(w, "tree_node", view) return } children := make([]treeChildView, 0, len(result.Entries)) for _, e := range result.Entries { full := joinTreePath(pathArg, e.Name) children = append(children, treeChildView{ Name: e.Name, Type: e.Type, Path: full, Size: e.Size, IsDir: e.Type == "dir", }) } // Stable order: dirs first, then files, alphabetically. sort.SliceStable(children, func(i, j int) bool { if children[i].IsDir != children[j].IsDir { return children[i].IsDir } return children[i].Name < children[j].Name }) page := hostRestoreTreePage{ HostID: host.ID, SnapshotID: snapshotID, Path: pathArg, Children: children, } view := s.baseView(r, u) view.Page = page if err := s.deps.UI.RenderPartial(w, "tree_node", view); err != nil { slog.Warn("ui restore tree: render partial", "err", err) } } // defaultRestoreTargetDir is the placeholder shown on the step-3 // New-directory radio card and the value used when the operator // leaves the field blank. The agent runs as root under systemd, so // we surface /root explicitly rather than $HOME — operators were // confused by "agent user's home" copy when the underlying user is // always root anyway. is substituted at dispatch. The unit // no longer pins ReadWritePaths (ProtectSystem=full + no ProtectHome), // so operators can point this at /home// directly // when they want a specific destination. func defaultRestoreTargetDir() string { return "/root/rm-restore//" } // looksLikeRestoreTarget validates the operator-supplied target dir // is a shape the agent can sensibly resolve. We accept absolute // paths and a couple of agent-side expansions ($HOME, ~/). Other env // vars are deliberately rejected — operator-supplied paths shouldn't // be able to pick up arbitrary agent env values. func looksLikeRestoreTarget(p string) bool { if p == "" { return false } switch { case strings.HasPrefix(p, "/"): return true case strings.HasPrefix(p, "$HOME/"), p == "$HOME": return true case strings.HasPrefix(p, "${HOME}/"), p == "${HOME}": return true case strings.HasPrefix(p, "~/"), p == "~": return true } return false } // sessionIDFromCookie returns the operator's session cookie value, // used as the cache key scope for the tree-list cache. Unauthenticated // requests don't reach this point, so an empty cookie value would // only happen if requireUIUser is bypassed in tests — fall back to // the request remote addr for those cases. func sessionIDFromCookie(r *stdhttp.Request) string { if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { return c.Value } return r.RemoteAddr } // joinTreePath combines a directory path and a child name into an // absolute snapshot-relative path, normalising any duplicate slashes. func joinTreePath(dir, name string) string { if dir == "" || dir == "/" { return "/" + name } return strings.TrimRight(dir, "/") + "/" + name } // satisfy unused-import if compile order shifts. var _ = ui.User{}