package http import ( "encoding/json" stdhttp "net/http" "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/store" ) // snapshotDiffRequest is the JSON body for POST .../snapshots/diff. // Either short or long snapshot IDs are accepted (restic's diff // command takes both). type snapshotDiffRequest struct { SnapshotA string `json:"snapshot_a"` SnapshotB string `json:"snapshot_b"` } // handleSnapshotDiff dispatches a JobDiff. Output streams as // log.stream lines to the standard live job page; the operator reads // the diff text directly there. Behaves like the run-now endpoints: // 503 if the host is offline, 400 if the IDs are missing, 422 if // they're not in the host's snapshot list (we don't want operators // running diffs against arbitrary snapshot strings). func (s *Server) handleSnapshotDiff(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") host, err := s.deps.Store.GetHost(r.Context(), hostID) if err != nil { writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") return } var req snapshotDiffRequest // HTMX form posts arrive as application/x-www-form-urlencoded; // the JSON shape is also accepted for REST callers. ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "application/x-www-form-urlencoded") { if err := r.ParseForm(); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_form", err.Error()) return } req.SnapshotA = strings.TrimSpace(r.PostForm.Get("snapshot_a")) req.SnapshotB = strings.TrimSpace(r.PostForm.Get("snapshot_b")) } else { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } req.SnapshotA = strings.TrimSpace(req.SnapshotA) req.SnapshotB = strings.TrimSpace(req.SnapshotB) } if req.SnapshotA == "" || req.SnapshotB == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_snapshot", "snapshot_a and snapshot_b are both required") return } if req.SnapshotA == req.SnapshotB { writeJSONError(w, stdhttp.StatusUnprocessableEntity, "same_snapshot", "diff requires two different snapshots") return } // Validate the IDs are known to this host. Match on long ID, short // ID, or any prefix match — operators sometimes paste a 6-char // shortened form. snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } resolveID := func(idOrShort string) string { for _, s := range snaps { if s.ID == idOrShort || s.ShortID == idOrShort { return s.ID } } // Prefix fallback (operator pasted 6 chars of a long id). for _, s := range snaps { if strings.HasPrefix(s.ID, idOrShort) { return s.ID } } return "" } a := resolveID(req.SnapshotA) b := resolveID(req.SnapshotB) if a == "" || b == "" { writeJSONError(w, stdhttp.StatusUnprocessableEntity, "snapshot_not_found", "one or both snapshot ids are not in this host's snapshot list") return } if !s.deps.Hub.Connected(host.ID) { writeJSONError(w, stdhttp.StatusServiceUnavailable, "host_offline", "agent is not connected; try again when it reconnects") return } jobID := ulid.Make().String() now := time.Now().UTC() if err := s.deps.Store.CreateJob(r.Context(), store.Job{ ID: jobID, HostID: host.ID, Kind: string(api.JobDiff), ActorKind: "user", ActorID: &user.ID, CreatedAt: now, }); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{ JobID: jobID, Kind: api.JobDiff, Diff: &api.DiffPayload{SnapshotA: a, SnapshotB: b}, }) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } if err := s.deps.Hub.Send(r.Context(), host.ID, env); err != nil { writeJSONError(w, stdhttp.StatusServiceUnavailable, "host_offline", err.Error()) return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &user.ID, Actor: "user", Action: "host.snapshot_diff", TargetKind: ptr("host"), TargetID: &host.ID, TS: now, }) jobURL := "/jobs/" + jobID if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", jobURL) w.WriteHeader(stdhttp.StatusNoContent) return } writeJSON(w, stdhttp.StatusAccepted, map[string]string{ "job_id": jobID, "job_url": jobURL, }) }