Files
crowdsec-dashy/internal/handlers/renderer.go
T

311 lines
12 KiB
Go

// 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,
// countryFlag returns the Unicode flag emoji for a 2-letter ISO code.
"countryFlag": func(code string) string {
if len(code) != 2 {
return ""
}
a := rune(code[0])
b := rune(code[1])
if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' {
return ""
}
return string([]rune{0x1F1E6 + a - 'A', 0x1F1E6 + b - 'A'})
},
}
}
// -----------------------------------------------------------------------
// 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: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`},
{Path: "/decisions", Label: "Decisions", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`},
{Path: "/alerts", Label: "Alerts", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`},
{Path: "/countries", Label: "Countries", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`},
{Path: "/bouncers", Label: "Bouncers", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`, Divider: true},
{Path: "/machines", Label: "Machines", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`},
{Path: "/allowlist", Label: "Allowlist", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`},
{Path: "/hub", Label: "Hub", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>`, Divider: true},
{Path: "/metrics-ui", Label: "Metrics", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>`},
{Path: "/config-editor", Label: "Config Editor", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`, Divider: true},
{Path: "/geoip", Label: "GeoIP DB", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`},
}
// 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
CrowdsecBinPath string
CrowdsecConfigDir string
}