package http import ( "errors" "io/fs" "log/slog" stdhttp "net/http" "strings" "time" "github.com/coder/websocket" "github.com/go-chi/chi/v5" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/web" ) // ----- static assets (Tailwind CSS, future favicon, etc) ------------- // staticHandler serves files embedded under web/static/ at /static/*. // Returns 404 for anything missing rather than the fs default 500. func staticHandler() stdhttp.Handler { sub, err := fs.Sub(web.FS, "static") if err != nil { // Embed.FS panics live at compile time; if Sub fails the binary // is genuinely broken — surface it loudly. panic("web: static subtree missing: " + err.Error()) } return stdhttp.StripPrefix("/static/", stdhttp.FileServer(stdhttp.FS(sub))) } // ----- session helpers ------------------------------------------------ // sessionUser resolves the request's session cookie to a User, or // (nil, nil) if the cookie is missing/expired/invalid. A non-nil // error means an underlying store failure; treat that as 500. func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { c, err := r.Cookie(sessionCookieName) if err != nil { return nil, nil } sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value)) if err != nil { // Treat "not found" / "expired" as "no session", not as fatal. if errors.Is(err, store.ErrNotFound) { return nil, nil } return nil, err } u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID) if err != nil { if errors.Is(err, store.ErrNotFound) { return nil, nil } return nil, err } return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil } // requireUIUser resolves the session and 303-redirects to /login if // there isn't one. Returns nil + emits the redirect when unauthed. // (HTML twin of jobs.go's API-style requireUser, which returns 401.) func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui.User { u, err := s.sessionUser(r) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return nil } if u == nil { stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) return nil } return u } // baseView populates the fields the nav partial needs on every // authenticated page. func (s *Server) baseView(u *ui.User, active string) ui.ViewData { return ui.ViewData{ User: u, Active: active, Version: s.version(), } } // version returns the binary's build version — passed in via Deps so // cmd/server's `var version` ends up here. func (s *Server) version() string { if s.deps.Version != "" { return s.deps.Version } return "dev" } // ----- handlers ------------------------------------------------------- // dashboardPage is the data the dashboard template renders against. type dashboardPage struct { Hosts []store.Host HostCount int Summary store.FleetSummary } // handleUIDashboard is the root page. Auth-gated; falls through to // /login if there is no session. func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } hosts, err := s.deps.Store.ListHosts(r.Context()) if err != nil { slog.Error("ui dashboard: list hosts", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } summary, err := s.deps.Store.FleetSummary(r.Context()) if err != nil { slog.Error("ui dashboard: fleet summary", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } view := s.baseView(u, "dashboard") view.OpenAlerts = summary.OpenAlerts view.Page = dashboardPage{ Hosts: hosts, HostCount: len(hosts), Summary: summary, } if err := s.deps.UI.Render(w, "dashboard", view); err != nil { slog.Error("ui: render dashboard", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // handleUIRunBackup is the form-submit twin of POST /api/hosts/{id}/jobs // that the dashboard / host-detail "Run now" buttons call via // hx-post. On success it sets HX-Redirect → /jobs/{job_id} so the // operator lands on the live log viewer for the job they just // kicked off. func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } hostID := chi.URLParam(r, "id") if hostID == "" { stdhttp.Error(w, "missing host id", stdhttp.StatusBadRequest) return } storeUser, _, err := s.userByID(r, u.ID) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, nil) if code != "" { stdhttp.Error(w, msg, status) return } // HTMX (with hx-post + hx-swap=none) doesn't honour HX-Redirect // when the response itself is a 3xx — fetch follows the redirect // first and the header is lost. Branch on the HX-Request marker // so HTMX gets a 200 + HX-Redirect (client-side window.location // hop), while plain form-post / curl callers get the 303. target := "/jobs/" + res.JobID if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", target) w.WriteHeader(stdhttp.StatusOK) return } stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther) } // addHostPage carries the form state into the Add host template. // In State A (form), Token is empty. In State B (result), Token is // populated and the template renders the install command. type addHostPage struct { // Form fields — pre-populate the form on a re-render after a // validation error. Hostname string Tags string RepoURL string RepoUsername string // Server URL the operator should paste into the install // command. Resolved from RM_BASE_URL falling back to the // request's Host header. ServerURL string // Banner-level error shown above the form. Error string // Result state. When Token != "", the template renders the // install command panel instead of the form. Token string ExpiresAt time.Time } // handleUIAddHostGet renders the empty Add host form. func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } view := s.baseView(u, "dashboard") view.Title = "Add host · restic-manager" view.Page = addHostPage{ServerURL: s.publicURL(r)} if err := s.deps.UI.Render(w, "add_host", view); err != nil { slog.Error("ui: render add_host", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // handleUIAddHostPost validates the form, mints the enrolment token // (with encrypted repo creds), and re-renders the same page in // "result" state showing the install command. func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } page := addHostPage{ Hostname: strings.TrimSpace(r.PostForm.Get("hostname")), Tags: strings.TrimSpace(r.PostForm.Get("tags")), RepoURL: strings.TrimSpace(r.PostForm.Get("repo_url")), RepoUsername: strings.TrimSpace(r.PostForm.Get("repo_username")), ServerURL: s.publicURL(r), } repoPassword := r.PostForm.Get("repo_password") if page.Hostname == "" { page.Error = "Hostname is required." } else if page.RepoURL == "" || repoPassword == "" { page.Error = "Repo URL and password are both required so the agent can back up the moment it comes online." } if page.Error == "" { token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword) switch err { case nil: page.Token = token page.ExpiresAt = expires case errMissingRepoCreds: page.Error = "Repo URL and password are both required." default: slog.Error("ui add_host: mint token", "err", err) page.Error = "Couldn’t mint a token — see the server log for details." } } view := s.baseView(u, "dashboard") view.Title = "Add host · restic-manager" view.Page = page status := stdhttp.StatusOK if page.Error != "" { status = stdhttp.StatusUnprocessableEntity } else { status = stdhttp.StatusCreated } w.WriteHeader(status) if err := s.deps.UI.Render(w, "add_host", view); err != nil { slog.Error("ui: render add_host", "err", err) } } // hostDetailPage carries everything the host detail template needs. type hostDetailPage struct { Host store.Host Snapshots []store.Snapshot // SnapshotsShown is the number rendered (we cap at ~50 for the // first slice; pagination lands when it matters). SnapshotsShown int } // handleUIHostDetail is the host detail page (snapshots tab by default). // Auth-gated. 404 if the host id is unknown. func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } hostID := chi.URLParam(r, "id") if hostID == "" { stdhttp.NotFound(w, r) return } 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 host detail: get host", "host_id", hostID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID) if err != nil { slog.Error("ui host detail: list snapshots", "host_id", hostID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } const cap = 50 shown := snaps if len(shown) > cap { shown = shown[:cap] } view := s.baseView(u, "dashboard") view.Title = host.Name + " · restic-manager" view.Page = hostDetailPage{ Host: *host, Snapshots: shown, SnapshotsShown: len(shown), } if err := s.deps.UI.Render(w, "host_detail", view); err != nil { slog.Error("ui: render host_detail", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // publicURL is what the operator should paste into the install // command. Prefers RM_BASE_URL (set by the operator's reverse // proxy config) and falls back to scheme + Host of the inbound // request — useful for local smoke without a proxy. func (s *Server) publicURL(r *stdhttp.Request) string { if s.deps.Cfg.BaseURL != "" { return strings.TrimRight(s.deps.Cfg.BaseURL, "/") } scheme := "http" if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" } return scheme + "://" + r.Host } // jobDetailPage carries everything the live-log template renders. type jobDetailPage struct { Job store.Job Host store.Host Logs []store.JobLogLine NextSeq int64 IsActive bool // true while status is queued|running } // handleUIJobDetail renders the live job log view (snapshot of any // already-persisted log lines + an empty stream container the JS // fills via the WS). func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } jobID := chi.URLParam(r, "id") if jobID == "" { stdhttp.NotFound(w, r) return } job, err := s.deps.Store.GetJob(r.Context(), jobID) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } host, err := s.deps.Store.GetHost(r.Context(), job.HostID) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } logs, err := s.deps.Store.ListJobLogs(r.Context(), jobID, 0, 0) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } var nextSeq int64 if n := len(logs); n > 0 { nextSeq = logs[n-1].Seq } view := s.baseView(u, "dashboard") view.Title = job.Kind + " · " + host.Name + " · restic-manager" view.Page = jobDetailPage{ Job: *job, Host: *host, Logs: logs, NextSeq: nextSeq, IsActive: job.Status == "queued" || job.Status == "running", } if err := s.deps.UI.Render(w, "job_detail", view); err != nil { slog.Error("ui: render job_detail", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // handleJobStream is the browser-side WS endpoint. Auth is via the // session cookie (the HTTP layer does the lookup before upgrading). // On connect we subscribe to JobHub for the given job_id; the // subscriber goroutine pumps fan-out messages to the client until // the job finishes or the browser navigates away. // // Messages on the wire are the same api.Envelope shape as on the // agent side, so the client-side JS can switch on env.type the // same way our Go code does. func (s *Server) handleJobStream(w stdhttp.ResponseWriter, r *stdhttp.Request) { if u, _ := s.sessionUser(r); u == nil { stdhttp.Error(w, "unauthorized", stdhttp.StatusUnauthorized) return } jobID := chi.URLParam(r, "id") if jobID == "" { stdhttp.Error(w, "missing job id", stdhttp.StatusBadRequest) return } if _, err := s.deps.Store.GetJob(r.Context(), jobID); err != nil { stdhttp.NotFound(w, r) return } conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ InsecureSkipVerify: true, // Origin checks pointless for a same-origin browser hop. }) if err != nil { slog.Warn("ws browser accept failed", "job_id", jobID, "err", err) return } defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() // Wrap so we get the same Send semantics as the agent path. c := ws.NewConn("browser-"+jobID, conn) s.deps.JobHub.Subscribe(r.Context(), jobID, c) } // userByID fetches the full store.User the UI session represents. // Returns the user, ok-flag, error. Used by handlers that need the // store-side row (e.g. for audit_log.user_id) rather than just the // projected ui.User. func (s *Server) userByID(r *stdhttp.Request, id string) (*store.User, bool, error) { u, err := s.deps.Store.GetUserByID(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { return nil, false, nil } return nil, false, err } return u, true, nil } // handleUILoginGet renders the login form. If the user is already // signed in we redirect them home — login is for the unauthenticated. func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { if u, _ := s.sessionUser(r); u != nil { stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) return } view := ui.ViewData{Version: s.version()} if err := s.deps.UI.Render(w, "login", view); err != nil { slog.Error("ui: render login", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // handleUILoginPost consumes the form, validates, mints a session, // and either redirects to / on success or re-renders the form with // an error banner on failure. func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } username := r.PostForm.Get("username") password := r.PostForm.Get("password") if _, err := s.authenticateAndSession(w, r, username, password); err != nil { // Re-render the form. Single generic message — see // authenticateAndSession's note on not leaking user existence. view := ui.ViewData{ Version: s.version(), Username: username, Error: "Invalid username or password.", } w.WriteHeader(stdhttp.StatusUnauthorized) if err := s.deps.UI.Render(w, "login", view); err != nil { slog.Error("ui: render login (post-fail)", "err", err) } return } stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) } // handleUILogoutPost is the form-submit twin of /api/auth/logout. It // drops the session cookie and redirects to /login. func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { if c, err := r.Cookie(sessionCookieName); err == nil { _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) } stdhttp.SetCookie(w, &stdhttp.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.deps.Cfg.CookieSecure, SameSite: stdhttp.SameSiteLaxMode, }) stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) }