package http import ( "errors" "io/fs" "log/slog" stdhttp "net/http" "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) } // 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) }