Files
restic-manager/internal/server/http/ui_handlers.go
T
steve 8fb1c100fd P2-04.5: kill host.default_paths in favour of manual schedules
Two independent path lists for "what does this host back up?" was
a real divergence footgun — operator types one set at Add-host time
and a different set into a schedule, both end up in the same repo,
the snapshot history looks fine until restore. Resolution: drop
host.default_paths entirely; add a `manual` flag on schedules.
A manual schedule has paths/excludes/tags/retention like any other
but no cron — it fires only via per-schedule Run-now. Single source
of truth for what gets backed up.

Schema (migration 0007):
* schedules.manual INTEGER NOT NULL DEFAULT 0.
* For every host with non-empty default_paths, seed a manual
  schedule with those paths and bump host_schedule_version.
* ALTER TABLE hosts DROP COLUMN default_paths.
* ALTER TABLE enrollment_tokens RENAME COLUMN default_paths
  TO initial_paths.

Original draft of this migration rebuilt hosts via the
create-new + drop-old + rename-new pattern. With foreign_keys=ON
(set in the connection DSN), DROP TABLE on the parent fired
ON DELETE CASCADE on every child of hosts(id) — schedules /
jobs / snapshots / host_credentials all wiped on the smoke env
when I tried it. SQLite 3.35+ supports column-level ALTERs
directly, so we skip the rebuild dance and avoid the cascade
trap. Six lines of SQL instead of sixty, no FK risk.

Run-now rewiring:
* New `dispatchScheduleNow(hostID, scheduleID, conn?)` helper
  unifies the agent-driven path (cron fire → schedule.fire →
  OnScheduleFire callback) and the UI-driven path (operator
  clicks Run-now on a schedule row). Conn arg is optional; nil
  falls back to Hub.Send.
* New POST /hosts/{id}/schedules/{sid}/run endpoint — per-row
  Run-now button on the schedules list.
* Dashboard's per-host Run-now (handleUIRunBackup) now picks the
  host's only enabled manual schedule, falls back to the only
  enabled schedule, else returns "pick one in Schedules tab".
  Keeps one-click for the common case.

Agent:
* Scheduler skips manual schedules in cron build (silent — they're
  a normal data shape, not an error).
* Wire Schedule struct gains Manual flag.
* Schedule.fire flow unchanged — the agent only ever fires
  non-manual schedules anyway.

UI:
* Add-host form retitled "Initial schedule · manual" so the
  operator knows the paths become an editable schedule under
  the Schedules tab. Result page calls out the manual schedule
  + points at Host > Schedules.
* Schedule edit form: "Manual schedule" checkbox at the top of
  the When section; toggling it hides/shows the cron field via
  inline JS. Server-side validator skips the cron requirement
  when manual=true.
* Schedule list shows a "manual" tag under the status pill and
  renders the When column as "— run-now only —" for manual rows.
  Each row gets a Run-now button when the schedule is enabled
  and the host is online.

Tests + go test ./... green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:26:06 +01:00

727 lines
22 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 (
"context"
"crypto/rand"
"encoding/base64"
"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 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
// Paths is the textarea-as-typed default-paths input. One path
// per line, blanks ignored.
Paths 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
// RepoPassword is the password the agent will use against the
// rest-server. When the operator left the password field blank
// we generate one server-side; PasswordGenerated tracks which
// path produced it so the result page can label it appropriately.
// Either way it's surfaced on the result page exactly once,
// inside the htpasswd snippet — same one-time-view rule as the
// enrolment token. Reload = gone.
RepoPassword string
PasswordGenerated bool
}
// 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")),
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 the operator didn't type a password, mint one. We surface it
// once on the result page (inside the htpasswd snippet) so they
// can paste it into the rest-server's htpasswd file.
if page.Error == "" && repoPassword == "" {
gen, err := generateRepoPassword()
if err != nil {
slog.Error("ui add_host: generate repo password", "err", err)
page.Error = "Couldnt generate a password — see the server log for details."
} else {
repoPassword = gen
page.PasswordGenerated = true
}
}
defaultPaths := splitPaths(page.Paths)
if page.Error == "" {
token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword, defaultPaths)
switch err {
case nil:
page.Token = token
page.ExpiresAt = expires
page.RepoPassword = repoPassword
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)
}
}
// 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)
}