// Package handlers provides the template renderer and all HTTP handlers. package handlers import ( "bytes" "fmt" "html/template" "io/fs" "log" "net/http" "net/url" "path" "strings" "crowdsec-dashy/internal/crowdsec" "crowdsec-dashy/internal/middleware" ) // ----------------------------------------------------------------------- // Renderer // ----------------------------------------------------------------------- // Renderer holds pre-parsed templates, one per page. type Renderer struct { templates map[string]*template.Template funcMap template.FuncMap } // bareLayoutPages lists page names that use base_bare.html instead of base.html. var bareLayoutPages = map[string]bool{ "login": true, } // NewRenderer parses all page templates. Most pages use the sidebar layout // (base.html); pages listed in bareLayoutPages use the bare layout (base_bare.html). func NewRenderer(fsys fs.FS) (*Renderer, error) { r := &Renderer{ templates: make(map[string]*template.Template), funcMap: buildFuncMap(), } const ( basePath = "templates/layouts/base.html" barePath = "templates/layouts/base_bare.html" ) pages, err := fs.Glob(fsys, "templates/pages/*.html") if err != nil { return nil, fmt.Errorf("renderer: glob pages: %w", err) } if len(pages) == 0 { return nil, fmt.Errorf("renderer: no page templates found") } for _, page := range pages { name := templateName(page) layout := basePath if bareLayoutPages[name] { layout = barePath } tmpl, err := template.New(path.Base(page)). Funcs(r.funcMap). ParseFS(fsys, layout, page) if err != nil { return nil, fmt.Errorf("renderer: parse %s: %w", name, err) } r.templates[name] = tmpl log.Printf("renderer: registered template %q (layout: %s)", name, path.Base(layout)) } return r, nil } // Render executes the named template into a buffer then writes to w. func (r *Renderer) Render(w http.ResponseWriter, name string, data any) { tmpl, ok := r.templates[name] if !ok { http.Error(w, fmt.Sprintf("template %q not found", name), http.StatusInternalServerError) return } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { log.Printf("renderer: execute %q: %v", name, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = buf.WriteTo(w) } // RenderError renders the error page. func (r *Renderer) RenderError(w http.ResponseWriter, code int, msg string) { w.WriteHeader(code) r.Render(w, "error", ErrorData{ PageData: PageData{CurrentPath: "/error", Title: fmt.Sprintf("Error %d", code)}, Code: code, Message: msg, }) } func templateName(p string) string { base := path.Base(p) return base[:len(base)-len(path.Ext(base))] } // ----------------------------------------------------------------------- // FuncMap // ----------------------------------------------------------------------- func buildFuncMap() template.FuncMap { return template.FuncMap{ "inc": func(i int) int { return i + 1 }, "dec": func(i int) int { return i - 1 }, // alertsPageURL builds the prev/next link for the alerts page. "alertsPageURL": func(f crowdsec.AlertFilter, page int, next bool, showUpdates bool) string { p := page - 1 if next { p = page + 1 } q := url.Values{} q.Set("page", fmt.Sprintf("%d", p)) if f.Scenario != "" { q.Set("scenario", f.Scenario) } if f.IP != "" { q.Set("ip", f.IP) } if f.Since != "" { q.Set("since", f.Since) } if showUpdates { q.Set("show_updates", "1") } return "/alerts?" + q.Encode() }, // dict builds a map for passing multiple values to a sub-template. // Usage: {{template "foo" dict "Key1" val1 "Key2" val2}} "dict": func(pairs ...any) (map[string]any, error) { if len(pairs)%2 != 0 { return nil, fmt.Errorf("dict: odd number of arguments") } m := make(map[string]any, len(pairs)/2) for i := 0; i < len(pairs); i += 2 { k, ok := pairs[i].(string) if !ok { return nil, fmt.Errorf("dict: key %v is not a string", pairs[i]) } m[k] = pairs[i+1] } return m, nil }, // decisionTypeBadge returns a CSS class suffix for a decision type. "decisionBadgeClass": func(t string) string { switch strings.ToLower(t) { case "ban": return "badge-red" case "captcha": return "badge-amber" case "throttle": return "badge-blue" default: return "badge-gray" } }, // originBadgeClass returns a CSS class for a decision origin. "originBadgeClass": func(o string) string { switch strings.ToLower(o) { case "crowdsec": return "badge-cyan" case "cscli": return "badge-purple" case "lists": return "badge-green" default: return "badge-gray" } }, // hubStatusClass returns a CSS class for a hub item status. "hubStatusClass": func(s string) string { switch strings.ToLower(s) { case "enabled": return "badge-green" case "disabled": return "badge-gray" case "update-available": return "badge-amber" default: return "badge-gray" } }, // safeHTML allows raw HTML in templates (use carefully). "safeHTML": func(s string) template.HTML { return template.HTML(s) //nolint:gosec }, // boolIcon returns a UTF-8 icon for boolean display. "boolIcon": func(b bool) string { if b { return "✓" } return "✗" }, // truncate shortens a string. "truncate": func(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }, // join joins a string slice. "join": strings.Join, } } // ----------------------------------------------------------------------- // Shared page data // ----------------------------------------------------------------------- // NavItem describes one sidebar navigation entry. type NavItem struct { Path string Label string Icon string Divider bool // show divider before this item } // SidebarNav returns the full navigation definition. var SidebarNav = []NavItem{ {Path: "/", Label: "Dashboard", Icon: ``}, {Path: "/decisions", Label: "Decisions", Icon: ``, Divider: false}, {Path: "/alerts", Label: "Alerts", Icon: ``}, {Path: "/bouncers", Label: "Bouncers", Icon: ``, Divider: true}, {Path: "/machines", Label: "Machines", Icon: ``}, {Path: "/hub", Label: "Hub", Icon: ``, Divider: true}, {Path: "/metrics-ui", Label: "Metrics", Icon: ``}, } // PageData contains fields available to every template. type PageData struct { CurrentPath string Title string Nav []NavItem Flash FlashMessage CLIAvailable bool PollInterval int CSRFToken string } // FlashMessage is a one-shot notification shown on the next page load. type FlashMessage struct { Type string // success, error, warning, info Message string } // ErrorData is passed to the error page template. type ErrorData struct { PageData Code int Message string } // NewPageData creates PageData from the current request. func NewPageData(r *http.Request, title string, cliAvail bool, pollSec int) PageData { return PageData{ CurrentPath: r.URL.Path, Title: title, Nav: SidebarNav, CLIAvailable: cliAvail, PollInterval: pollSec, CSRFToken: middleware.CSRFFromContext(r), } } // WithFlash attaches a flash message to PageData. func (pd PageData) WithFlash(flashType, msg string) PageData { pd.Flash = FlashMessage{Type: flashType, Message: msg} return pd } // ----------------------------------------------------------------------- // Shared handler dependencies // ----------------------------------------------------------------------- // Deps holds shared dependencies injected into every handler. type Deps struct { Renderer *Renderer LAPI *crowdsec.LAPIClient CLI *crowdsec.CLIClient CLIAvailable bool PollInterval int }