// Package pages provides server-side HTML rendering via Go templates. // // Templates are embedded in the binary via //go:embed and organized as: // // templates/base.html — outer shell (banner + surface block + scripts) // templates/login.html — standalone login page // templates/components/*.html — reusable partials (model-select, team-select, chat-pane, etc.) // templates/surfaces/*.html — one per surface (chat, editor, notes, admin, etc.) // templates/admin/*.html — admin sub-pages (roles, routing, providers, etc.) package pages import ( "context" "crypto/rand" "embed" "encoding/hex" "encoding/json" "fmt" "html/template" "io" "log" "net/http" "strings" "sync" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/store" ) //go:embed templates/*.html templates/components/*.html templates/surfaces/*.html templates/admin/*.html var templateFS embed.FS // Engine holds parsed templates and rendering state. type Engine struct { mu sync.RWMutex templates *template.Template cfg *config.Config stores store.Stores loaders map[string]DataLoaderFunc devMode bool } // BannerConfig holds environment banner settings. type BannerConfig struct { Text string `json:"text"` Color string `json:"color"` Background string `json:"background"` Visible bool `json:"visible"` } // PageData is passed to every template render. type PageData struct { Banner BannerConfig Surface string // active surface ID Section string // sub-section (for admin pages) CSPNonce string BasePath string Version string Environment string User *UserContext Data any // surface-specific data from loader // v0.22.7: Theme + settings injection Theme string // "dark", "light", or "" (= use default) SurfaceSettings any // JSON-serialized to window.__SETTINGS__ // v0.22.7: Login/splash page fields InstanceName string // branding: instance display name LogoURL string // branding: custom logo URL Tagline string // branding: tagline under instance name RegistrationOpen bool // whether self-registration is enabled } // UserContext is the authenticated user's info available to templates. type UserContext struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` Role string `json:"role"` Email string `json:"email"` } // DataLoaderFunc loads surface-specific data for template rendering. type DataLoaderFunc func(c *gin.Context, stores store.Stores) (any, error) // New creates a template engine. Call after config and stores are ready. func New(cfg *config.Config, stores store.Stores) *Engine { e := &Engine{ cfg: cfg, stores: stores, loaders: make(map[string]DataLoaderFunc), devMode: gin.Mode() == gin.DebugMode, } e.parseTemplates() e.registerLoaders() return e } // parseTemplates compiles all embedded templates with the custom FuncMap. func (e *Engine) parseTemplates() { funcMap := template.FuncMap{ "toJSON": toJSON, "eq": func(a, b string) bool { return a == b }, "contains": stringContains, "roleFilterType": roleFilterType, "esc": template.HTMLEscapeString, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "join": strings.Join, "dict": dict, "or": tmplOr, } tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html", "templates/components/*.html", "templates/surfaces/*.html", "templates/admin/*.html", ) if err != nil { log.Fatalf("❌ Failed to parse templates: %v", err) } e.mu.Lock() e.templates = tmpl e.mu.Unlock() var names []string for _, t := range tmpl.Templates() { if t.Name() != "" { names = append(names, t.Name()) } } log.Printf("[pages] Parsed %d templates", len(names)) } // RegisterLoader adds a named data loader for a surface. func (e *Engine) RegisterLoader(name string, fn DataLoaderFunc) { e.loaders[name] = fn } // Render renders a named template into the response. func (e *Engine) Render(c *gin.Context, name string, data PageData) { data.BasePath = e.cfg.BasePath data.Version = Version data.Environment = e.cfg.Environment data.CSPNonce = generateNonce() data.Banner = e.loadBanner() // v0.22.7: Default theme if not set if data.Theme == "" { data.Theme = "dark" } e.mu.RLock() tmpl := e.templates e.mu.RUnlock() c.Header("Content-Type", "text/html; charset=utf-8") c.Status(http.StatusOK) if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil { log.Printf("[pages] Template render error (%s): %v", name, err) } } // RenderSurface is a Gin handler factory for surface routes. func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc { return func(c *gin.Context) { user := e.getUserContext(c) var data any if loader, ok := e.loaders[surfaceID]; ok { var err error data, err = loader(c, e.stores) if err != nil { log.Printf("[pages] Data loader error (%s): %v", surfaceID, err) c.String(http.StatusInternalServerError, "Failed to load page data") return } } section := c.Param("section") if section == "" && surfaceID == "admin" { section = "overview" } if section == "" && surfaceID == "settings" { section = "general" } e.Render(c, "base.html", PageData{ Surface: surfaceID, Section: section, User: user, Data: data, }) } } // RenderLogin serves the standalone login page. func (e *Engine) RenderLogin() gin.HandlerFunc { return func(c *gin.Context) { // v0.22.7: Populate splash/login fields from global config instanceName, logoURL, tagline := e.loadBranding() regOpen := e.isRegistrationOpen() e.Render(c, "login.html", PageData{ Surface: "login", InstanceName: instanceName, LogoURL: logoURL, Tagline: tagline, RegistrationOpen: regOpen, }) } } // getUserContext extracts user info from gin context (set by auth middleware). func (e *Engine) getUserContext(c *gin.Context) *UserContext { userID, exists := c.Get("user_id") if !exists { return nil } return &UserContext{ ID: userID.(string), Role: c.GetString("role"), } } // loadBanner reads banner config from global settings. func (e *Engine) loadBanner() BannerConfig { if e.stores.GlobalConfig == nil { return BannerConfig{} } raw, err := e.stores.GlobalConfig.Get(context.Background(), "banner") if err != nil || raw == nil { return BannerConfig{} } b := BannerConfig{} if v, ok := raw["enabled"].(bool); ok { b.Visible = v } if v, ok := raw["text"].(string); ok { b.Text = v } if v, ok := raw["fg"].(string); ok { b.Color = v } if v, ok := raw["bg"].(string); ok { b.Background = v } return b } // loadBranding reads instance branding from global settings. func (e *Engine) loadBranding() (name, logoURL, tagline string) { name = "Chat Switchboard" // default if e.stores.GlobalConfig == nil { return } raw, err := e.stores.GlobalConfig.Get(context.Background(), "branding") if err != nil || raw == nil { return } if v, ok := raw["instance_name"].(string); ok && v != "" { name = v } if v, ok := raw["logo_url"].(string); ok { logoURL = v } if v, ok := raw["tagline"].(string); ok { tagline = v } return } // isRegistrationOpen checks if self-registration is enabled. func (e *Engine) isRegistrationOpen() bool { if e.stores.GlobalConfig == nil { return false } raw, err := e.stores.GlobalConfig.Get(context.Background(), "registration") if err != nil || raw == nil { return false } if v, ok := raw["enabled"].(bool); ok { return v } return false } // Version is injected at build time via ldflags. var Version = "dev" // SetVersion allows main.go to inject the build version. func SetVersion(v string) { Version = v } // WriteError writes an error page directly to the response. func (e *Engine) WriteError(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) fmt.Fprintf(w, `
%s
`, status, template.HTMLEscapeString(msg)) } // ── Template FuncMap helpers ───────────────── func toJSON(v any) template.JS { b, err := json.Marshal(v) if err != nil { return template.JS("null") } return template.JS(b) } func stringContains(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false } // roleFilterType returns the model_type filter for a given role name. func roleFilterType(role string) string { switch role { case "embedding": return "embedding" case "utility": return "chat" default: return "" } } // dict creates a map from alternating key/value pairs for template use. func dict(pairs ...any) map[string]any { m := make(map[string]any, len(pairs)/2) for i := 0; i < len(pairs)-1; i += 2 { key, ok := pairs[i].(string) if !ok { continue } m[key] = pairs[i+1] } return m } // tmplOr returns the first non-empty string argument. func tmplOr(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } func generateNonce() string { b := make([]byte, 16) if _, err := io.ReadFull(rand.Reader, b); err != nil { return "fallback-nonce" } return hex.EncodeToString(b) }