- 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
117 lines
4.6 KiB
Go
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())
|
|
}
|