package server import ( "context" "crypto/subtle" "net/http" "net/url" "strings" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/session" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/version" ) const ( // csrfCookieName holds the double-submit CSRF token. It is independent of // the session so the login form (which has no session yet) is protected too. csrfCookieName = "email_agent_csrf" sessionTTL = session.DefaultTTL ) // ctxKey is the private context key type for request-scoped values. type ctxKey int const sessionCtxKey ctxKey = iota // requireAuth redirects unauthenticated requests to the login screen and // otherwise puts the session on the request context. func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess, ok := s.currentSession(r) if !ok { target := "/login?next=" + url.QueryEscape(r.URL.RequestURI()) http.Redirect(w, r, target, http.StatusSeeOther) return } next(w, r.WithContext(context.WithValue(r.Context(), sessionCtxKey, sess))) } } func (s *Server) currentSession(r *http.Request) (session.Session, bool) { c, err := r.Cookie(session.CookieName) if err != nil || c.Value == "" { return session.Session{}, false } return s.sessions.Lookup(c.Value) } func sessionFrom(r *http.Request) session.Session { sess, _ := r.Context().Value(sessionCtxKey).(session.Session) return sess } // loginSecret is the password the single user must supply. EMAIL_AGENT_PASSWORD // wins; otherwise the session secret doubles as the password so a single // configured secret is enough. func (s *Server) loginSecret() string { if s.cfg.Password != "" { return s.cfg.Password } return s.cfg.SessionSecret } // csrfToken returns the current double-submit token, minting one when absent. func (s *Server) csrfToken(w http.ResponseWriter, r *http.Request) string { if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" { return c.Value } tok, err := session.RandomToken(32) if err != nil { // crypto/rand failing is fatal for security; surface it rather than // issuing an unprotected form. s.logger.Error("csrf token generation failed", "error", err) return "" } http.SetCookie(w, &http.Cookie{ Name: csrfCookieName, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.cfg.IsProduction(), MaxAge: int(sessionTTL.Seconds()), }) return tok } // csrfOK verifies the double-submit token from the form or the X-CSRF-Token // header. func (s *Server) csrfOK(r *http.Request) bool { c, err := r.Cookie(csrfCookieName) if err != nil || c.Value == "" { return false } tok := r.FormValue("csrf") if tok == "" { tok = r.Header.Get("X-CSRF-Token") } return subtle.ConstantTimeCompare([]byte(tok), []byte(c.Value)) == 1 } func (s *Server) setSessionCookie(w http.ResponseWriter, value string, expires time.Time) { http.SetCookie(w, &http.Cookie{ Name: session.CookieName, Value: value, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.cfg.IsProduction(), Expires: expires, MaxAge: int(sessionTTL.Seconds()), }) } func (s *Server) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: session.CookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.cfg.IsProduction(), MaxAge: -1, }) } type loginData struct { PartOfDay string Error string Next string CSRFToken string Configured bool } func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, code int, errMsg, next string) { // Mint the CSRF cookie before the status line, or Set-Cookie is dropped. csrf := s.csrfToken(w, r) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(code) data := loginData{ PartOfDay: partOfDay(time.Now()), Error: errMsg, Next: next, CSRFToken: csrf, Configured: s.loginSecret() != "", } if err := s.tmpl.ExecuteTemplate(w, "login.html", data); err != nil { s.logger.Error("render login", "error", err) } } // handleLoginGet renders the sign-in form. func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) { if _, ok := s.currentSession(r); ok { http.Redirect(w, r, cleanNext(r.URL.Query().Get("next")), http.StatusSeeOther) return } s.renderLogin(w, r, http.StatusOK, "", cleanNext(r.URL.Query().Get("next"))) } // handleLoginPost validates the CSRF token and password, then starts a session. // POST (not GET) means the password is never placed in the URL or browser // history. func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.renderLogin(w, r, http.StatusBadRequest, "Could not read the sign-in form.", "/") return } next := cleanNext(r.FormValue("next")) if !s.csrfOK(r) { s.renderLogin(w, r, http.StatusForbidden, "Your sign-in page expired. Please try again.", next) return } secret := s.loginSecret() if secret == "" { s.renderLogin(w, r, http.StatusServiceUnavailable, "Login is not configured yet.", next) return } if subtle.ConstantTimeCompare([]byte(r.FormValue("password")), []byte(secret)) != 1 { s.logger.Warn("login failed", "remote", r.RemoteAddr) s.renderLogin(w, r, http.StatusUnauthorized, "Incorrect password.", next) return } sess, cookie, err := s.sessions.New("Sam") if err != nil { s.logger.Error("create session", "error", err) http.Error(w, "could not start a session", http.StatusInternalServerError) return } s.setSessionCookie(w, cookie, sess.ExpiresAt) s.logger.Info("login ok", "user", sess.User, "remote", r.RemoteAddr) http.Redirect(w, r, next, http.StatusSeeOther) } // handleLogout destroys the session and returns to the login screen. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if c, err := r.Cookie(session.CookieName); err == nil && c.Value != "" { s.sessions.Destroy(c.Value) } s.clearSessionCookie(w) http.Redirect(w, r, "/login", http.StatusSeeOther) } type navItem struct { Name string Color string } type dashboardData struct { Greeting string User string CSRFToken string Version string Categories []navItem Folders []navItem } // handleDashboard renders the empty inbox shell. func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { sess := sessionFrom(r) data := dashboardData{ Greeting: partOfDay(time.Now()), User: sess.User, CSRFToken: s.csrfToken(w, r), Version: version.Version, // Placeholders until FR-14 (editable categories) and FR-9 (folders) // land. Colours are the design system's decorative sticker palette. Categories: []navItem{ {Name: "Action", Color: "var(--primary)"}, {Name: "Important", Color: "var(--sticker-orange)"}, {Name: "Info", Color: "var(--sticker-sky)"}, {Name: "Spam", Color: "var(--sticker-red)"}, {Name: "Newsletter", Color: "var(--sticker-purple)"}, {Name: "Auto-reply", Color: "var(--sticker-teal)"}, }, Folders: []navItem{ {Name: "Replied-to", Color: "var(--sticker-green)"}, {Name: "Delete", Color: "var(--sticker-red)"}, {Name: "Saved", Color: "var(--sticker-teal)"}, }, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil { s.logger.Error("render dashboard", "error", err) } } // handleInboxPartial is the HTMX fragment for the centre list. Phase 1.2 has no // mail yet, so it renders the empty state. func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "inbox.html", nil); err != nil { s.logger.Error("render inbox partial", "error", err) } } // cleanNext only allows same-site, absolute-path redirects. func cleanNext(next string) string { if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { return "/" } return next } // partOfDay returns the time-aware greeting prefix. func partOfDay(t time.Time) string { switch h := t.Hour(); { case h < 12: return "Good morning" case h < 18: return "Good afternoon" default: return "Good evening" } }