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