72d8081b0d
The pending page suppressed the htpasswd snippet when repo_username was blank — but with --private-repos the username is required for auth, and operators routinely leave the field blank assuming the system will pick something sensible. * handleUIAddHostPost defaults repo_username to the typed hostname when blank. Matches what --private-repos expects (URL path segment == username). * pending_host.html: snippet now renders whenever a password is present (always true after the generate-on-blank logic landed earlier). * Form help-text updated to describe the default explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
839 lines
26 KiB
Go
839 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
|
||
}
|
||
}
|
||
|
||
// 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 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)
|
||
}
|