368 lines
9.4 KiB
Go
368 lines
9.4 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, 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 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
|
|
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["color"].(string); ok {
|
|
b.Color = v
|
|
}
|
|
if v, ok := raw["background"].(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, `<!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)
|
|
}
|