Files
restic-manager/internal/server/http/ui_handlers.go
T
steve 9795492f2e P1-27: Add host flow — form + minted-token result page
GET /hosts/new renders the focused two-column form (hostname,
tags, repo URL/username/password). POST /hosts/new validates,
mints a one-time token via the new mintEnrollmentToken helper —
shared with the existing JSON /api/enrollment-tokens endpoint —
and re-renders the same page in result state showing:

  - the install command with RM_SERVER + RM_TOKEN filled in (and
    an inline copy-to-clipboard button),
  - an "awaiting agent connection" panel with the hostname
    pre-filled,
  - a troubleshooting list pointing at the most common reasons
    the agent doesn't appear,
  - back-to-dashboard / add-another-host links.

publicURL() resolves RM_BASE_URL first, falling back to scheme +
Host on the inbound request — useful for local smoke without a
proxy.

Browser-verified end-to-end: form submit → token minted → install
command renders with the right values from the form input.

template fn formatRelTime now accepts time.Time *or* *time.Time
so templates can pass either without fighting Go's lack of an
address-of operator.

Deferred: download-preconfigured-installer (a templated .sh with
the values baked in) — copy-paste covers v1; nice-to-have later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:16:54 +01:00

353 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"errors"
"io/fs"
"log/slog"
stdhttp "net/http"
"strings"
"time"
"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/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's "Run now" buttons call via hx-post. Returns
// 204 on success — HTMX swap=none means "did the thing, no DOM
// change needed." Failures return text in the body so HTMX's
// response-header inspection surfaces it.
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
}
_, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, nil)
if code != "" {
stdhttp.Error(w, msg, status)
return
}
w.WriteHeader(stdhttp.StatusNoContent)
}
// 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 = "Couldnt 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)
}
}
// 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
}
// 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)
}