package http import ( "crypto/rand" "encoding/base64" "encoding/json" "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/restic" "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 { // Missing or invalid cookie just means the caller isn't logged // in — that's a normal state, not a server error. Return // (nil, nil) so callers can decide between "redirect to login" // and "treat as anonymous". return nil, nil //nolint:nilerr } 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. Every UI page sits under the dashboard primary // nav today; if a future page lives under a different primary nav // tab (e.g. Settings, Audit), accept an Active arg again. func (s *Server) baseView(u *ui.User) ui.ViewData { return ui.ViewData{ User: u, Active: "dashboard", 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 []dashboardHostRow HostCount int Summary store.FleetSummary PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d) } // dashboardHostRow carries a host plus the per-row Run-now decision // the host_row partial needs. The decision is computed server-side // once per render rather than recomputed in the template. type dashboardHostRow struct { Host store.Host // RunAllScheduleID is the ID of the single schedule that covers // every source group on the host. Empty when zero or 2+ schedules // match — in that case the row shows "Open →" instead of a Run-now // button (the operator picks per-group from the host detail). RunAllScheduleID string // NextRun is the next-fire time of RunAllScheduleID (when set), // computed server-side from its cron. nil otherwise. NextRun *time.Time } // pickRunAllSchedule returns the ID of the single schedule whose // source-group set ⊇ every source group on the host. Returns "" when // zero or 2+ such "covering" schedules exist (operator-disambiguation // belongs on the host detail, not the dashboard one-click). func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string { if len(groups) == 0 || len(scheds) == 0 { return "" } groupIDs := make(map[string]struct{}, len(groups)) for _, g := range groups { groupIDs[g.ID] = struct{}{} } matched := "" for _, sc := range scheds { if !sc.Enabled { continue } // Treat sc.SourceGroupIDs as a set; check it covers every group. got := make(map[string]struct{}, len(sc.SourceGroupIDs)) for _, gid := range sc.SourceGroupIDs { got[gid] = struct{}{} } covers := true for gid := range groupIDs { if _, ok := got[gid]; !ok { covers = false break } } if !covers { continue } if matched != "" { // Two distinct covering schedules — ambiguous, bail out. return "" } matched = sc.ID } return matched } // 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 } // Per-host: pick the single covering schedule (if any) so the row // can render a one-click Run-now where it's unambiguous. Two store // calls per host — fine at fleet sizes we care about. rows := make([]dashboardHostRow, 0, len(hosts)) for _, h := range hosts { row := dashboardHostRow{Host: h} groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID) if gerr != nil { slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr) } scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID) if serr != nil { slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr) } row.RunAllScheduleID = pickRunAllSchedule(scheds, groups) if row.RunAllScheduleID != "" { for _, sc := range scheds { if sc.ID == row.RunAllScheduleID { if parsed, perr := cronParser.Parse(sc.CronExpr); perr == nil { n := parsed.Next(time.Now().UTC()).UTC() row.NextRun = &n } break } } } rows = append(rows, row) } pending, perr := s.deps.Store.ListPendingHosts(r.Context(), time.Now().UTC()) if perr != nil { slog.Warn("ui dashboard: list pending hosts", "err", perr) } view := s.baseView(u) view.OpenAlerts = summary.OpenAlerts view.Page = dashboardPage{ Hosts: rows, HostCount: len(hosts), Summary: summary, PendingHosts: pending, } if err := s.deps.UI.Render(w, "dashboard", view); err != nil { slog.Error("ui: render dashboard", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // Per-host Run-now and manual Init-repo were retired by the P2 redesign. // Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs // automatically on the agent's first WS connect after enrolment. Both // routes return 410 Gone so any cached browser tab gets a clear error. func (s *Server) handleUIRunBackupGone(w stdhttp.ResponseWriter, r *stdhttp.Request) { stdhttp.Error(w, "per-host Run-now has moved — use POST /hosts/{id}/source-groups/{gid}/run", stdhttp.StatusGone) } func (s *Server) handleUIInitRepoGone(w stdhttp.ResponseWriter, r *stdhttp.Request) { stdhttp.Error(w, "manual init-repo is gone — the server auto-inits on the agent's first connect", stdhttp.StatusGone) } // addHostPage carries the Add-host form state. The result-state // (showing the install command + htpasswd snippet) lives at // /hosts/pending/{token} and uses pendingHostPage instead, so the // operator can refresh / bookmark / come back later — the password // is decrypted from the still-alive token row on every render // rather than living only in a one-shot rendered response. 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 Paths string ServerURL string Error string } // pendingHostPage is the GET /hosts/pending/{token} view. Lives // for as long as the token does (1h ttl); once the agent enrols, // the handler redirects to /hosts/{host_id} and this page is gone. type pendingHostPage struct { Token string ServerURL string ExpiresAt time.Time RepoURL string RepoUsername string RepoPassword string InitialPaths []string } // 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) 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 303-redirects to the persistent // pending-host page. On validation errors we re-render the form // with the operator's typed input intact and a banner. 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")), Paths: r.PostForm.Get("paths"), ServerURL: s.publicURL(r), } repoPassword := r.PostForm.Get("repo_password") if page.Hostname == "" { page.Error = "Hostname is required." } else if page.RepoURL == "" { page.Error = "Repo URL is required so the agent can back up the moment it comes online." } if page.Error == "" && repoPassword == "" { gen, err := generateRepoPassword() if err != nil { slog.Error("ui add_host: generate repo password", "err", err) page.Error = "Couldn’t generate a password — see the server log for details." } else { repoPassword = gen } } // Default repo username to the hostname when the operator left it // blank. With rest-server's --private-repos this is what the URL // path segment is expected to be anyway, and an htpasswd entry // always needs *some* user — defaulting saves the operator from // landing on a pending page with a half-formed snippet. repoUsername := page.RepoUsername if repoUsername == "" { repoUsername = page.Hostname } if page.Error == "" { token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths)) switch { case err == nil: stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther) return case errors.Is(err, 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) view.Title = "Add host · restic-manager" view.Page = page w.WriteHeader(stdhttp.StatusUnprocessableEntity) if err := s.deps.UI.Render(w, "add_host", view); err != nil { slog.Error("ui: render add_host", "err", err) } } // handleUIPendingHost serves the durable Add-host result page — // shown after a successful POST /hosts/new and reachable until the // agent enrols (the page redirects to /hosts/{id} once that // happens) or the token expires (1h ttl). The password is // re-decrypted from the encrypted token row on every render so // the operator can refresh, bookmark, navigate away and come back. func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } rawToken := chi.URLParam(r, "token") if rawToken == "" { stdhttp.NotFound(w, r) return } tokHash := auth.HashToken(rawToken) status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), tokHash) if err != nil { stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther) return } if status.ConsumedHost != nil { stdhttp.Redirect(w, r, "/hosts/"+*status.ConsumedHost, stdhttp.StatusSeeOther) return } if time.Now().After(status.ExpiresAt) { stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther) return } att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), tokHash) if err != nil { slog.Warn("ui pending: load attachments", "err", err) stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther) return } page := pendingHostPage{ Token: rawToken, ServerURL: s.publicURL(r), ExpiresAt: status.ExpiresAt, InitialPaths: att.InitialPaths, } if att.EncRepoCreds != "" { plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+tokHash)) if err != nil { slog.Error("ui pending: decrypt creds", "err", err) } else { var blob repoCredsBlob if err := json.Unmarshal(plain, &blob); err == nil { page.RepoURL = blob.RepoURL page.RepoUsername = blob.RepoUsername page.RepoPassword = blob.RepoPassword } } } view := s.baseView(u) view.Title = "Pending host · restic-manager" view.Page = page if err := s.deps.UI.Render(w, "pending_host", view); err != nil { slog.Error("ui: render pending_host", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // handleUIPendingAwaiting is the polled fragment that the pending- // host page swaps in every couple of seconds to detect "agent // connected". Returns either the still-awaiting partial (with the // HTMX poll trigger preserved) or the connected partial (no poll — // includes a meta-refresh to /hosts/{id} so the operator lands on // the host detail). func (s *Server) handleUIPendingAwaiting(w stdhttp.ResponseWriter, r *stdhttp.Request) { if u := s.requireUIUser(w, r); u == nil { return } rawToken := chi.URLParam(r, "token") if rawToken == "" { stdhttp.Error(w, "missing token", stdhttp.StatusBadRequest) return } status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), auth.HashToken(rawToken)) page := awaitingFragment{Token: rawToken, ExpiresAt: status.ExpiresAt} switch { case errors.Is(err, store.ErrNotFound): page.State = "expired" case err != nil: slog.Warn("ui awaiting: lookup", "err", err) page.State = "expired" case status.ConsumedHost != nil: page.State = "connected" page.HostID = *status.ConsumedHost if h, err := s.deps.Store.GetHost(r.Context(), *status.ConsumedHost); err == nil { page.HostName = h.Name page.LastSeenAt = h.LastSeenAt } case time.Now().After(status.ExpiresAt): page.State = "expired" default: page.State = "awaiting" } if err := s.deps.UI.RenderPartial(w, "awaiting_agent", ui.ViewData{Page: page}); err != nil { slog.Error("ui: render awaiting_agent", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // awaitingFragment carries the state for the polled awaiting-agent // partial. State == awaiting | connected | expired drives both the // copy block and whether HTMX keeps polling. type awaitingFragment struct { State string Token string ExpiresAt time.Time HostID string HostName string LastSeenAt *time.Time } // hostChromeData is the field set the host_chrome partial reads from // every host-detail-tab page's Page struct. Embed it as the first // (anonymous) field of the page struct so .Page.Host / .Page.SubTab // resolve via field promotion in the template. type hostChromeData struct { Host store.Host SubTab string // snapshots | sources | schedules | repo Crumb string // breadcrumb tail ("snapshots" / "sources" / etc) SourceGroupCount int ScheduleCount int ScheduleVersion int64 // host_schedule_version (latest desired) // Auto-init status surfaced from the latest 'init' job. // InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run). InitStatus string InitAt *time.Time // started_at if non-nil else created_at InitJobID string // Latest 'restore' job — surfaced as a small line below the // init-status one so the operator has at-a-glance visibility into // recent destructive activity. Empty status means no restore has // ever run on this host. RestoreStatus string RestoreAt *time.Time RestoreJobID string } // loadHostChrome fetches the per-tab counts that every host-detail tab // renders in the chrome (sub-tab badges + version indicator). On any // non-fatal store error it logs and degrades to zeros — better to // render the page with stale counts than 500 the whole tab. func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData { d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb} if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil { d.SourceGroupCount = len(groups) } else { slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err) } if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil { d.ScheduleCount = len(scheds) } else { slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err) } if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil { d.ScheduleVersion = v } if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "init"); err == nil && j != nil { d.InitStatus = j.Status d.InitJobID = j.ID t := j.CreatedAt if j.StartedAt != nil { t = *j.StartedAt } d.InitAt = &t } if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "restore"); err == nil && j != nil { d.RestoreStatus = j.Status d.RestoreJobID = j.ID t := j.CreatedAt if j.StartedAt != nil { t = *j.StartedAt } d.RestoreAt = &t } return d } // hostDetailPage carries everything the host detail template needs. type hostDetailPage struct { hostChromeData Snapshots []store.Snapshot // SnapshotsShown is the number rendered (we cap at ~50 for the // first slice; pagination lands when it matters). SnapshotsShown int // LegacyRestic is true when the host's restic version predates // 0.17, in which case `restic snapshots --json` doesn't embed the // per-snapshot summary block and the Size/Files columns render // blank. The template uses this to attach a tooltip to those // column headers explaining the version requirement. LegacyRestic bool } // 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) view.Title = host.Name + " · restic-manager" view.Page = hostDetailPage{ hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"), Snapshots: shown, SnapshotsShown: len(shown), LegacyRestic: !restic.Env{Version: host.ResticVersion}.AtLeastVersion(0, 17), } 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) } } // generateRepoPassword returns a 24-byte URL-safe random string for // use as a per-host rest-server password. URL-safe alphabet keeps // it shell-safe inside single quotes — important since the operator // pastes it into an `htpasswd -i` invocation on the rest-server. func generateRepoPassword() (string, error) { var buf [24]byte if _, err := rand.Read(buf[:]); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf[:]), nil } // splitPaths parses the textarea content into a clean []string — // one path per line, leading/trailing whitespace trimmed, blanks // dropped. func splitPaths(s string) []string { out := []string{} for _, line := range strings.Split(s, "\n") { if p := strings.TrimSpace(line); p != "" { out = append(out, p) } } return out } // 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) 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, "unauthorised", 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) // Register first so future broadcasts reach us, then re-fetch the // job to close the late-subscriber race: a fast-failing job can // finish (DB write + Broadcast) before the browser's WS hop // completes, leaving the JS waiting forever for a job.finished // that already passed. If the job is already terminal here, prime // the subscriber with a synthetic job.finished so the JS reloads. sub := s.deps.JobHub.Register(jobID) if cur, gerr := s.deps.Store.GetJob(r.Context(), jobID); gerr == nil && isTerminalJobStatus(cur.Status) { if env, ferr := buildSyntheticJobFinished(cur); ferr == nil { sub.Send(env) } } sub.Run(r.Context(), c) } func isTerminalJobStatus(s string) bool { switch api.JobStatus(s) { case api.JobSucceeded, api.JobFailed, api.JobCancelled: return true } return false } func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) { var fin time.Time if job.FinishedAt != nil { fin = *job.FinishedAt } exit := 0 if job.ExitCode != nil { exit = *job.ExitCode } errMsg := "" if job.Error != nil { errMsg = *job.Error } return api.Marshal(api.MsgJobFinished, "", api.JobFinishedPayload{ JobID: job.ID, Status: api.JobStatus(job.Status), ExitCode: exit, FinishedAt: fin, Stats: job.Stats, Error: errMsg, }) } // 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) }