initial commit
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
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>"))
|
||||
}
|
||||
Reference in New Issue
Block a user