9795492f2e
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>
353 lines
11 KiB
Go
353 lines
11 KiB
Go
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 = "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)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|