package http import ( "errors" "io/fs" "log/slog" stdhttp "net/http" "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 ------------------------------------------------------- // 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 } view := s.baseView(u, "dashboard") if err := s.deps.UI.Render(w, "dashboard", view); err != nil { slog.Error("ui: render dashboard", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } } // 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) }