Changeset 0.22.5 (#147)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
305
server/pages/pages.go
Normal file
305
server/pages/pages.go
Normal file
@@ -0,0 +1,305 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user