// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "bytes" "html/template" "log/slog" "net/http" ) // SafeTemplates wraps *html/template.Template to ensure all rendering goes // through a buffer. This prevents partial HTML from being written to the // response if template execution fails mid-way — the stdlib streams directly // to the writer and cannot roll back a partial write. // // Handlers hold a *SafeTemplates instead of *template.Template. The underlying // template set is unexported, so callers cannot bypass the buffer by calling // ExecuteTemplate on the ResponseWriter directly — the compiler prevents it. type SafeTemplates struct { tmpl *template.Template logger *slog.Logger } // NewSafeTemplates wraps tmpl and logger for safe buffered rendering. func NewSafeTemplates(tmpl *template.Template, logger *slog.Logger) *SafeTemplates { return &SafeTemplates{tmpl: tmpl, logger: logger} } // RenderErrorPage writes a styled full-page error response for navigation // requests. HTMX partial requests (HX-Request header) get the plain-text // error the client-side toast contract expects, and a failure to render the // error template itself also falls back to plain text, so this never emits a // partial page. func (s *SafeTemplates) RenderErrorPage(w http.ResponseWriter, r *http.Request, status int, msg string) { s.renderErrorPage(w, r, ErrorPageData{ Status: status, StatusText: http.StatusText(status), Message: msg, Action: ErrorPageAction{Label: "Back to the dashboard", Href: "/"}, }) } // RenderAuthFailure writes the page a person lands on when an authentication // flow refuses, satisfying internal/auth's FailurePage. // // It differs from RenderErrorPage in the two things that are wrong about the // ordinary error page here. The heading is the named failure rather than the // status line, because "400 Bad Request" tells the person nothing about a // sign-in that expired. And the way out is the flow they were in rather than // the dashboard, which is the page they could not reach. func (s *SafeTemplates) RenderAuthFailure(w http.ResponseWriter, r *http.Request, status int, heading, actionLabel, actionHref string) { s.renderErrorPage(w, r, ErrorPageData{ Status: status, StatusText: http.StatusText(status), Title: heading, Action: ErrorPageAction{Label: actionLabel, Href: actionHref}, }) } // renderErrorPage is the body both error pages share. func (s *SafeTemplates) renderErrorPage(w http.ResponseWriter, r *http.Request, data ErrorPageData) { // The body differs by HX-Request; tell shared caches (chrome-conventions). w.Header().Add("Vary", "HX-Request") if r.Header.Get("HX-Request") == "true" { http.Error(w, data.plain(), data.Status) return } var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, "error.html", data); err != nil { s.logger.Error("error page render failed", slog.Int("status", data.Status), slog.Any("error", err)) http.Error(w, data.plain(), data.Status) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(data.Status) w.Write(buf.Bytes()) } // Fragment executes the named template into a buffer and returns its HTML, // for the one case a page needs rendered markup as a value rather than as // a response: a form field's declared slot region (forms.Binding.Slots), // whose first content is a partial the same set holds. A failure logs and // yields the empty string, so a broken fragment costs the region and not // the page. func (s *SafeTemplates) Fragment(name string, data any) template.HTML { var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil { s.logger.Error("template fragment execution failed", slog.String("template", name), slog.Any("error", err)) return "" } return template.HTML(buf.String()) } // Render executes the named template into a buffer and writes it to w only on // success. On failure it logs the error and writes a 500 error fragment, // guaranteeing the response is never partial or truncated. func (s *SafeTemplates) Render(w http.ResponseWriter, name string, data any) { var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil { s.logger.Error("template execution failed", slog.String("template", name), slog.Any("error", err)) w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(``)) return } w.Header().Set("Content-Type", "text/html") w.Write(buf.Bytes()) }