Files
vibedns/internal/web/render.go
T
owenandClaude Sonnet 5 e6deb5fe84 Fix stale block page preview by cache-busting static assets
The preview iframe stayed blank after a redeploy because /static/js/app.js
is served with a 24h Cache-Control and no versioning, so browsers kept
using the pre-existing cached copy that predated the preview code. Static
CSS/JS references now carry a ?v=<build commit> query string so a new
build is never masked by a stale cache. Also makes the CSP's frame-src
explicit for the preview iframe rather than relying on the default-src
fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
2026-08-17 00:35:26 -05:00

607 lines
15 KiB
Go

package web
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"math"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/version"
webui "github.com/owen/vibedns/web"
)
// PageData is the envelope every template receives. Page-specific values live
// under .Data; everything else is chrome the layout needs.
type PageData struct {
Title string
Nav string
Subnav string
User auth.Principal
CSRF string
Flashes []Flash
Alerts []Alert
Version string
Asset string // cache-busting suffix for /static URLs; changes every build
Now time.Time
Data any
Query url.Values
BasePath string
}
// assetVersion busts the browser cache for /static assets on every new
// build, so a redeploy is never masked by a day-old cached app.js. It falls
// back to the semantic version when no VCS commit was embedded (e.g. a build
// outside a git checkout), which is still stable within one running process.
var assetVersion = func() string {
if version.Commit != "" {
return version.Commit
}
return version.Version
}()
// Alert is a persistent banner such as "a restart is required".
type Alert struct {
Level string // warning, danger, info
Title string
Message string
Link string
LinkText string
}
// templates holds one parsed template set per page.
type templates struct {
sets map[string]*template.Template
}
// layoutFiles are parsed into every page set.
var layoutFiles = []string{"layout.html", "partials.html"}
// loadTemplates parses each page against the shared layout.
//
// Each page gets its own template set rather than one global set, because Go
// templates are keyed by name: two pages both defining "content" in a single
// set would silently overwrite each other.
func loadTemplates(funcs template.FuncMap) (*templates, error) {
src := webui.Templates()
pages, err := fs.Glob(src, "pages/*.html")
if err != nil {
return nil, fmt.Errorf("list page templates: %w", err)
}
if len(pages) == 0 {
return nil, fmt.Errorf("no page templates were found in the binary")
}
t := &templates{sets: make(map[string]*template.Template, len(pages))}
for _, page := range pages {
name := strings.TrimSuffix(path.Base(page), ".html")
files := append(append([]string{}, layoutFiles...), page)
set, err := template.New("layout.html").Funcs(funcs).ParseFS(src, files...)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", page, err)
}
t.sets[name] = set
}
return t, nil
}
// render executes a page template into a buffer first, so a template error
// produces a proper error page instead of a half-written response.
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data PageData) {
set, ok := s.tmpl.sets[page]
if !ok {
s.log.Error("template not found", "page", page)
s.renderError(w, r, http.StatusInternalServerError, "This page could not be rendered.")
return
}
data.Version = version.Version
data.Asset = assetVersion
data.Now = time.Now()
if data.Query == nil {
data.Query = r.URL.Query()
}
data.Flashes = append(data.Flashes, takeFlashes(w, r)...)
data.Alerts = append(data.Alerts, s.systemAlerts(r)...)
var buf bytes.Buffer
if err := set.ExecuteTemplate(&buf, "layout.html", data); err != nil {
s.log.Error("could not render page", "page", page, "error", err)
s.renderError(w, r, http.StatusInternalServerError, "This page could not be rendered.")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if _, err := buf.WriteTo(w); err != nil {
s.log.Debug("could not write response", "error", err)
}
}
// renderError shows a friendly error page. Stack traces and internal error
// text never reach the browser.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
set, ok := s.tmpl.sets["error"]
if !ok {
http.Error(w, message, status)
return
}
data := PageData{
Title: http.StatusText(status),
Version: version.Version,
Asset: assetVersion,
Now: time.Now(),
Data: map[string]any{
"Status": status,
"Text": http.StatusText(status),
"Message": message,
},
}
if p, ok := auth.PrincipalFrom(r.Context()); ok {
data.User = p
data.CSRF = s.app.Auth.IssueCSRFToken(p.Name)
}
var buf bytes.Buffer
if err := set.ExecuteTemplate(&buf, "layout.html", data); err != nil {
http.Error(w, message, status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
// systemAlerts assembles the banners shown across every page.
func (s *Server) systemAlerts(r *http.Request) []Alert {
var out []Alert
if admin, err := s.app.Admin(r.Context()); err == nil && admin.MustChangePassword {
out = append(out, Alert{
Level: "warning",
Title: "Change the generated password",
Message: "This account still uses the password printed at first startup. Set your own before exposing the interface.",
Link: "/account",
LinkText: "Change it now",
})
}
if pending := s.app.PendingRestart(r.Context()); len(pending) > 0 {
out = append(out, Alert{
Level: "info",
Title: "Restart required",
Message: "These settings are saved but will not take effect until the server restarts: " + strings.Join(pending, "; ") + ".",
})
}
if s.app.PendingRestore() {
out = append(out, Alert{
Level: "danger",
Title: "Database restore staged",
Message: "A backup will replace the live database the next time this server starts.",
Link: "/settings/database",
LinkText: "Review",
})
}
if problems := s.app.Snapshot().Problems; len(problems) > 0 {
out = append(out, Alert{
Level: "warning",
Title: fmt.Sprintf("%d record(s) could not be loaded", len(problems)),
Message: "Some records are invalid and are not being served. " +
"Open the affected zone to see which ones.",
Link: "/zones",
LinkText: "Review zones",
})
}
return out
}
// templateFuncs are the helpers available to every template.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"num": humanNumber,
"bytes": humanBytes,
"pct": formatPercent,
"ms": formatMillis,
"duration": formatDuration,
"timeAgo": timeAgo,
"datetime": formatDateTime,
"dateOnly": func(t time.Time) string { return t.Local().Format("2006-01-02") },
"timeOnly": func(t time.Time) string { return t.Local().Format("15:04:05") },
"rfc3339": func(t time.Time) string { return t.UTC().Format(time.RFC3339) },
"zeroTime": func(t time.Time) bool { return t.IsZero() },
"dict": dict,
"list": func(v ...any) []any { return v },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"seq": seq,
"join": strings.Join,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"contains": strings.Contains,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"title": titleCase,
"trimDot": func(s string) string { return strings.TrimSuffix(s, ".") },
"truncate": truncate,
"default": defaultValue,
"yesno": func(b bool) string {
if b {
return "Yes"
}
return "No"
},
"badgeFor": badgeFor,
"rcodeBadge": rcodeBadge,
"sourceBadge": sourceBadge,
"typeBadge": typeBadge,
"withQuery": withQuery,
"pages": paginationRange,
"json": toJSON,
"rdataFields": validate.SplitRData,
"boolstr": boolString,
"toggleIcon": toggleIcon,
"toggleVerb": toggleVerb,
"statusWord": statusWord,
"pick": ternary,
"nl2br": nl2br,
"lines": func(s string) []string { return strings.Split(strings.TrimSpace(s), "\n") },
"joinLines": func(v []string) string { return strings.Join(v, "\n") },
}
}
func humanNumber(v any) string {
var n int64
switch t := v.(type) {
case int:
n = int64(t)
case int32:
n = int64(t)
case int64:
n = t
case uint32:
n = int64(t)
case float64:
n = int64(t)
default:
return fmt.Sprint(v)
}
s := strconv.FormatInt(n, 10)
neg := strings.HasPrefix(s, "-")
s = strings.TrimPrefix(s, "-")
var out []string
for len(s) > 3 {
out = append([]string{s[len(s)-3:]}, out...)
s = s[:len(s)-3]
}
out = append([]string{s}, out...)
res := strings.Join(out, ",")
if neg {
return "-" + res
}
return res
}
func humanBytes(v any) string {
var n float64
switch t := v.(type) {
case int:
n = float64(t)
case int64:
n = float64(t)
case float64:
n = t
default:
return fmt.Sprint(v)
}
const unit = 1024.0
if n < unit {
return fmt.Sprintf("%.0f B", n)
}
units := []string{"KB", "MB", "GB", "TB"}
for _, u := range units {
n /= unit
if n < unit {
return fmt.Sprintf("%.1f %s", n, u)
}
}
return fmt.Sprintf("%.1f PB", n)
}
func formatPercent(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return "0.0%"
}
return fmt.Sprintf("%.1f%%", v)
}
func formatMillis(v float64) string {
if v < 1 {
return fmt.Sprintf("%.2f ms", v)
}
return fmt.Sprintf("%.1f ms", v)
}
func formatDuration(d time.Duration) string {
if d < time.Millisecond {
return fmt.Sprintf("%.0f µs", float64(d.Microseconds()))
}
if d < time.Second {
return fmt.Sprintf("%.1f ms", float64(d.Microseconds())/1000)
}
return d.Round(time.Millisecond).String()
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return "never"
}
return t.Local().Format("2006-01-02 15:04:05")
}
func timeAgo(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < 0:
return "just now"
case d < time.Minute:
return fmt.Sprintf("%d seconds ago", int(d.Seconds()))
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "a minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "an hour ago"
}
return fmt.Sprintf("%d hours ago", h)
case d < 30*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "yesterday"
}
return fmt.Sprintf("%d days ago", days)
default:
return t.Local().Format("2006-01-02")
}
}
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict needs an even number of arguments")
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
m[key] = values[i+1]
}
return m, nil
}
func seq(from, to int) []int {
if to < from {
return nil
}
out := make([]int, 0, to-from+1)
for i := from; i <= to; i++ {
out = append(out, i)
}
return out
}
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func truncate(n int, s string) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}
func defaultValue(def, v any) any {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) == "" {
return def
}
case nil:
return def
case int:
if t == 0 {
return def
}
}
return v
}
// badgeFor maps an enabled flag to a Bootstrap badge class.
func badgeFor(enabled bool) string {
if enabled {
return "text-bg-success"
}
return "text-bg-secondary"
}
func rcodeBadge(rcode string) string {
switch strings.ToUpper(rcode) {
case "NOERROR":
return "text-bg-success"
case "NXDOMAIN":
return "text-bg-warning"
case "REFUSED", "SERVFAIL", "DROPPED":
return "text-bg-danger"
default:
return "text-bg-secondary"
}
}
func sourceBadge(source string) string {
switch source {
case "authoritative":
return "text-bg-primary"
case "cache":
return "text-bg-info"
case "stale":
return "text-bg-warning"
case "recursive":
return "text-bg-secondary"
case "blocked":
return "text-bg-danger"
case "refused", "ratelimited":
return "text-bg-dark"
case "error":
return "text-bg-danger"
default:
return "text-bg-light text-dark"
}
}
// typeBadge colours a record type so the record table scans quickly.
func typeBadge(t string) string {
switch strings.ToUpper(t) {
case "A", "AAAA":
return "type-addr"
case "CNAME", "DNAME":
return "type-alias"
case "MX", "SRV", "NAPTR", "SVCB", "HTTPS":
return "type-service"
case "NS", "SOA":
return "type-auth"
case "TXT", "SPF", "CAA":
return "type-text"
case "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "TLSA", "SSHFP":
return "type-sec"
case "PTR":
return "type-ptr"
default:
return "type-other"
}
}
// withQuery rebuilds the current query string with one key replaced, which is
// what pagination and sort links need.
func withQuery(q url.Values, pairs ...any) template.URL {
next := url.Values{}
for k, v := range q {
next[k] = append([]string{}, v...)
}
for i := 0; i+1 < len(pairs); i += 2 {
key := fmt.Sprint(pairs[i])
val := fmt.Sprint(pairs[i+1])
if val == "" {
next.Del(key)
} else {
next.Set(key, val)
}
}
if len(next) == 0 {
return template.URL("?")
}
return template.URL("?" + next.Encode())
}
// paginationRange returns the page numbers to show around the current page.
func paginationRange(current, total int) []int {
if total <= 1 {
return nil
}
const window = 2
start := current - window
if start < 1 {
start = 1
}
end := current + window
if end > total {
end = total
}
return seq(start, end)
}
// toJSON renders a value as JSON for a data- attribute.
//
// It deliberately returns a plain string rather than template.JS: the value is
// always placed in an HTML attribute, where html/template escapes it, and the
// page reads it back with JSON.parse. That keeps every byte of page data out
// of inline <script> blocks, which the Content-Security-Policy forbids.
func toJSON(v any) (string, error) {
b, err := jsonMarshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
// ternary picks between two values, for the small either/or choices templates
// make inline (an icon name, a CSS class) where a full if/else is noise.
// It is registered as "pick" because "if" is a template keyword.
func ternary(cond bool, whenTrue, whenFalse any) any {
if cond {
return whenTrue
}
return whenFalse
}
// boolString renders a bool as a form value the server will parse back.
func boolString(b bool) string {
if b {
return "true"
}
return "false"
}
// toggleIcon picks the switch icon showing the current state.
func toggleIcon(enabled bool) string {
if enabled {
return "bi-toggle-on"
}
return "bi-toggle-off"
}
// toggleVerb names the action a toggle button performs.
func toggleVerb(currentlyEnabled bool) string {
if currentlyEnabled {
return "Disable"
}
return "Enable"
}
// statusWord labels the current state.
func statusWord(enabled bool) string {
if enabled {
return "Enabled"
}
return "Disabled"
}
func nl2br(s string) template.HTML {
escaped := template.HTMLEscapeString(s)
return template.HTML(strings.ReplaceAll(escaped, "\n", "<br>"))
}