This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/pages/pages.go
Jeffrey Smith 45fe965c32 Changeset 0.22.5 (#147)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-03 09:58:23 +00:00

306 lines
7.6 KiB
Go

// 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, 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 classification 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
User *UserContext
Data any // surface-specific data from loader
}
// 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.CSPNonce = generateNonce()
data.Banner = e.loadBanner()
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) {
e.Render(c, "login.html", PageData{
Surface: "login",
})
}
}
// 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["color"].(string); ok {
b.Color = v
}
if v, ok := raw["background"].(string); ok {
b.Background = v
}
return b
}
// 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, `<!DOCTYPE html><html><head><title>Error</title></head><body><h1>%d</h1><p>%s</p></body></html>`, 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)
}