c1f85da55f
Two issues from a smoke session:
1. The awaiting-agent panel never refreshed — operator had to go
back to the dashboard to see the host had connected.
2. Generated passwords were displayed only on the POST response.
Navigating away (or even an accidental tab close) lost them
permanently, so the operator couldn't update the rest-server's
htpasswd.
Both are the same fix: convert the POST-rendered transient
"result state" into a durable GET page at /hosts/pending/{token}.
* New route GET /hosts/pending/{token} renders the install-command +
htpasswd snippet view. Password is decrypted from the (still-
encrypted-at-rest) token row on every render — operator can
refresh, bookmark, navigate away and come back. Once the agent
enrols, the page redirects to /hosts/{id}; once the token
expires, redirect to /hosts/new.
* New route GET /hosts/pending/{token}/awaiting returns a polled
HTML fragment that the pending page swaps in every 2s via HTMX.
States: awaiting (keep polling) | connected (show "Open host →"
+ "View schedules" CTAs, polling stops) | expired (mint-new
link, polling stops). Polling stops naturally because only the
awaiting state's wrapper carries the hx-trigger attribute.
* POST /hosts/new now 303-redirects to /hosts/pending/{token}
on success; validation errors keep re-rendering the form with
banner.
Supporting changes:
* New store helper Store.GetEnrollmentTokenStatus(tokenHash) for
the polling endpoint — returns {expires_at, consumed_at,
consumed_host} in one round-trip without dragging in the
attachments-decryption path.
* New ui.Renderer.RenderPartial(w, name, data) for HTMX fragment
responses (no layout wrap). Picks an arbitrary page's template
set as the lookup point — every page parses the full common-
paths list, so they all see every partial.
* add_host.html stripped to form-only; pending_host.html owns the
result-state UI; awaiting_agent.html is the polled partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
829 lines
26 KiB
Go
829 lines
26 KiB
Go
package http
|
||
|
||
import (
|
||
"context"
|
||
"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/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
|
||
}
|
||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
stdhttp.NotFound(w, r)
|
||
return
|
||
}
|
||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host.RepoInitialisedAt == nil {
|
||
stdhttp.Error(w,
|
||
"this host's repo hasn't been initialised yet — click Initialise repo first",
|
||
stdhttp.StatusBadRequest)
|
||
return
|
||
}
|
||
pick, err := s.pickRunNowSchedule(r.Context(), hostID)
|
||
if err != nil {
|
||
stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest)
|
||
return
|
||
}
|
||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, pick.Paths)
|
||
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)
|
||
}
|
||
|
||
// pickRunNowSchedule chooses which schedule a generic per-host
|
||
// "Run now" button should dispatch when the operator hasn't picked
|
||
// one explicitly. Picks in priority order: the host's only enabled
|
||
// manual schedule, then its only enabled schedule of any kind.
|
||
// Returns a friendly error if there's nothing to run, or if the
|
||
// operator needs to disambiguate.
|
||
func (s *Server) pickRunNowSchedule(ctx context.Context, hostID string) (*store.Schedule, error) {
|
||
rows, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
|
||
if err != nil {
|
||
return nil, errFmt("internal: %s", err)
|
||
}
|
||
enabled := make([]store.Schedule, 0, len(rows))
|
||
for _, r := range rows {
|
||
if r.Enabled {
|
||
enabled = append(enabled, r)
|
||
}
|
||
}
|
||
if len(enabled) == 0 {
|
||
return nil, errFmt("this host has no enabled schedules — add one in the Schedules tab")
|
||
}
|
||
manuals := []store.Schedule{}
|
||
for _, r := range enabled {
|
||
if r.Manual {
|
||
manuals = append(manuals, r)
|
||
}
|
||
}
|
||
switch {
|
||
case len(manuals) == 1:
|
||
s := manuals[0]
|
||
return &s, nil
|
||
case len(enabled) == 1:
|
||
s := enabled[0]
|
||
return &s, nil
|
||
default:
|
||
return nil, errFmt("this host has %d schedules — pick one from the Schedules tab", len(enabled))
|
||
}
|
||
}
|
||
|
||
func errFmt(format string, args ...any) error {
|
||
return errFmtf(format, args...)
|
||
}
|
||
|
||
// handleUIInitRepo dispatches a one-shot `restic init` job for a
|
||
// host. Surfaced in the run-now panel as a red "Initialise repo"
|
||
// button when host.repo_initialised_at IS NULL. On success it
|
||
// redirects to the live log page just like Run-now.
|
||
func (s *Server) handleUIInitRepo(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
|
||
}
|
||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
stdhttp.NotFound(w, r)
|
||
return
|
||
}
|
||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||
return
|
||
}
|
||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobInit, nil)
|
||
if code != "" {
|
||
stdhttp.Error(w, msg, status)
|
||
return
|
||
}
|
||
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 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, "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 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
|
||
}
|
||
}
|
||
|
||
if page.Error == "" {
|
||
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword, splitPaths(page.Paths))
|
||
switch err {
|
||
case nil:
|
||
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
|
||
return
|
||
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
|
||
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, "dashboard")
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// 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, "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)
|
||
|
||
// 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,
|
||
})
|
||
}
|
||
|
||
// 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)
|
||
}
|