593 lines
14 KiB
Go
593 lines
14 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
|
|
Now time.Time
|
|
Data any
|
|
Query url.Values
|
|
BasePath string
|
|
}
|
|
|
|
// 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.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,
|
|
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>"))
|
|
}
|