Files
member-console/internal/server/render.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00

117 lines
4.6 KiB
Go

// 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(`<div class="alert alert-danger" role="alert"><strong>Error:</strong> Failed to render content. Try again.</div>`))
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(buf.Bytes())
}