All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m51s
CI/CD / build-and-deploy (pull_request) Successful in 1m13s
Lift block rendering to kernel SDK primitives (sw.renderers + sw.markdown) so all surfaces share one markdown pipeline. Rewrite docs for external audience — remove all fork history references. Add Mermaid architecture diagrams, CONTRIBUTING guide, and extension tutorial. - sw.renderers SDK module: kernel-level renderer registry - sw.markdown SDK module: unified marked v16 + DOMPurify pipeline - Browser extension script loader for renderer injection - Notes + Docs surfaces migrated to sw.markdown.renderSync() - 4 renderer extensions rewritten to IIFE + sw.renderers.register() - 6 Mermaid diagrams in ARCHITECTURE.md - CONTRIBUTING.md + TUTORIAL-FIRST-EXTENSION.md - DESIGN-WORKFLOWS.md replaces fork-era design doc - Surface alias routes removed from main.go - ICD/SDK runners migrated to /admin/packages/ endpoints - 13 new renderer tests - Docs, CHANGELOG, ROADMAP cleaned of fork references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
969 lines
27 KiB
Go
969 lines
27 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 (admin, settings, team-admin, extension)
|
|
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"
|
|
|
|
"switchboard-core/config"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
//go:embed templates/*.html templates/components/*.html templates/surfaces/*.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
|
|
surfaces []SurfaceManifest
|
|
devMode bool
|
|
}
|
|
|
|
// SurfaceManifest describes a surface's registration. Core surfaces are
|
|
// registered in Go at startup. Extension surfaces will be registered from
|
|
// manifest files (future).
|
|
type SurfaceManifest struct {
|
|
ID string `json:"id"` // unique identifier: "admin", "settings", "my-dashboard"
|
|
Route string `json:"route"` // primary URL pattern: "/", "/admin"
|
|
AltRoutes []string `json:"alt_routes"` // additional URL patterns
|
|
Title string `json:"title"` // human-readable: "Admin", "Settings"
|
|
Template string `json:"template"` // Go template name: "surface-admin", "surface-extension"
|
|
Components []string `json:"components"` // component IDs used: ["file-tree"]
|
|
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
|
|
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
|
|
Styles []string `json:"styles"` // CSS files (surface-specific)
|
|
Auth string `json:"auth"` // "authenticated", "public", "session", "admin"
|
|
Layout string `json:"layout"` // default pane layout preset: "single", "editor"
|
|
Source string `json:"source"` // "core" or "extension"
|
|
}
|
|
|
|
// BannerConfig holds environment banner settings.
|
|
type BannerConfig struct {
|
|
Text string `json:"text"`
|
|
Color string `json:"color"`
|
|
Background string `json:"background"`
|
|
Visible bool `json:"visible"`
|
|
}
|
|
|
|
// MessageConfig holds dismissible message bar settings.
|
|
type MessageConfig struct {
|
|
Text string `json:"text"`
|
|
Variant string `json:"variant"` // info, warn, error, success
|
|
Visible bool `json:"visible"`
|
|
}
|
|
|
|
// FooterConfig holds optional footer bar settings.
|
|
type FooterConfig struct {
|
|
Text string `json:"text"`
|
|
Visible bool `json:"visible"`
|
|
}
|
|
|
|
// PageData is passed to every template render.
|
|
type PageData struct {
|
|
Banner BannerConfig
|
|
Message MessageConfig
|
|
Footer FooterConfig
|
|
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
|
|
|
|
Theme string // "dark", "light", or "" (= use default)
|
|
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
|
|
|
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
|
|
|
EnabledSurfaces []string `json:"-"`
|
|
|
|
ExtensionSurfaces []ExtensionNavItem `json:"-"`
|
|
|
|
BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection)
|
|
|
|
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
|
|
AuthMode string // "builtin", "mtls", "oidc"
|
|
}
|
|
|
|
// SurfaceEnabled returns true if the given surface ID is in the EnabledSurfaces list.
|
|
// Used by templates: {{if .SurfaceEnabled "editor"}}
|
|
func (pd PageData) SurfaceEnabled(id string) bool {
|
|
for _, s := range pd.EnabledSurfaces {
|
|
if s == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ExtensionNavItem holds the minimal info needed to render an extension
|
|
// surface link in the sidebar nav.
|
|
type ExtensionNavItem struct {
|
|
ID string
|
|
Title string
|
|
Route string
|
|
}
|
|
|
|
// ConfigSectionEntry describes a package-declared config section for
|
|
// lazy-loading in Settings, Admin, or Team Admin surfaces.
|
|
type ConfigSectionEntry struct {
|
|
PackageID string `json:"package_id"` // section key & asset path prefix
|
|
Label string `json:"label"` // nav link text
|
|
Icon string `json:"icon"` // optional SVG path data (admin CatIcon format)
|
|
Component string `json:"component"` // relative asset path, e.g. "js/config.js"
|
|
Category string `json:"category"` // admin only: category tab (default "system")
|
|
}
|
|
|
|
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
|
|
// Queries the DB at render time so newly installed surfaces appear immediately.
|
|
func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
|
if e.stores.Packages == nil {
|
|
return nil
|
|
}
|
|
ctx := context.Background()
|
|
surfaces, err := e.stores.Packages.List(ctx)
|
|
if err != nil {
|
|
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
|
|
return nil
|
|
}
|
|
|
|
var items []ExtensionNavItem
|
|
for _, sr := range surfaces {
|
|
if sr.Source == "core" || !sr.Enabled {
|
|
continue
|
|
}
|
|
// Only surface and full types have routes — extensions are headless
|
|
if sr.Type != "surface" && sr.Type != "full" {
|
|
continue
|
|
}
|
|
route := ""
|
|
if sr.Manifest != nil {
|
|
route, _ = sr.Manifest["route"].(string)
|
|
}
|
|
if route == "" {
|
|
route = "/s/" + sr.ID
|
|
}
|
|
items = append(items, ExtensionNavItem{
|
|
ID: sr.ID,
|
|
Title: sr.Title,
|
|
Route: route,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
// browserExtensionIDs returns IDs of enabled browser-tier extensions.
|
|
// These extensions have JS scripts that should be injected into every page
|
|
// so they can register renderers with the SDK.
|
|
func (e *Engine) browserExtensionIDs() []string {
|
|
if e.stores.Packages == nil {
|
|
return nil
|
|
}
|
|
ctx := context.Background()
|
|
pkgs, err := e.stores.Packages.List(ctx)
|
|
if err != nil {
|
|
log.Printf("[pages] Failed to load browser extensions: %v", err)
|
|
return nil
|
|
}
|
|
|
|
var ids []string
|
|
for _, pkg := range pkgs {
|
|
if pkg.Enabled && pkg.Type == "extension" && pkg.Tier == "browser" {
|
|
ids = append(ids, pkg.ID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// 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()
|
|
e.registerCoreSurfaces()
|
|
e.SeedSurfaces()
|
|
return e
|
|
}
|
|
|
|
// registerCoreSurfaces populates the surface manifest list with the kernel
|
|
// surfaces. Extension surfaces are appended from DB at startup.
|
|
func (e *Engine) registerCoreSurfaces() {
|
|
e.surfaces = []SurfaceManifest{
|
|
{
|
|
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
|
|
Title: "Admin", Template: "surface-admin", Auth: "admin",
|
|
DataRequires: []string{"admin"},
|
|
Layout: "single", Source: "core",
|
|
},
|
|
{
|
|
ID: "settings", Route: "/settings/:section", AltRoutes: []string{"/settings"},
|
|
Title: "Settings", Template: "surface-settings", Auth: "authenticated",
|
|
DataRequires: []string{"settings"},
|
|
Layout: "single", Source: "core",
|
|
},
|
|
{
|
|
ID: "team-admin", Route: "/team-admin/:section", AltRoutes: []string{"/team-admin"},
|
|
Title: "Team Admin", Template: "surface-team-admin", Auth: "authenticated",
|
|
DataRequires: []string{"team-admin"},
|
|
Layout: "single", Source: "core",
|
|
},
|
|
{
|
|
ID: "workflow", Route: "/w/:id",
|
|
Title: "Workflow", Template: "workflow", Auth: "session",
|
|
DataRequires: []string{"workflow"},
|
|
Layout: "single", Source: "core",
|
|
},
|
|
{
|
|
ID: "workflow-landing", Route: "/w/:id/:slug",
|
|
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
|
|
Layout: "single", Source: "core",
|
|
},
|
|
{
|
|
ID: "welcome", Route: "/welcome",
|
|
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
|
|
Layout: "single", Source: "core",
|
|
},
|
|
{
|
|
ID: "docs", Route: "/docs/:section", AltRoutes: []string{"/docs"},
|
|
Title: "Docs", Template: "surface-docs", Auth: "authenticated",
|
|
Layout: "single", Source: "core",
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetSurface returns the manifest for a surface by ID, or nil if not found.
|
|
func (e *Engine) GetSurface(id string) *SurfaceManifest {
|
|
for i := range e.surfaces {
|
|
if e.surfaces[i].ID == id {
|
|
return &e.surfaces[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Surfaces returns all registered surface manifests.
|
|
func (e *Engine) Surfaces() []SurfaceManifest {
|
|
return e.surfaces
|
|
}
|
|
|
|
// 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,
|
|
"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",
|
|
)
|
|
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()
|
|
data.Message = e.loadMessage()
|
|
data.Footer = e.loadFooter()
|
|
|
|
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 = "users"
|
|
}
|
|
if section == "" && surfaceID == "settings" {
|
|
section = "general"
|
|
}
|
|
if section == "" && surfaceID == "team-admin" {
|
|
section = "members"
|
|
}
|
|
if section == "" && surfaceID == "docs" {
|
|
section = "GETTING-STARTED"
|
|
}
|
|
|
|
e.Render(c, "base.html", PageData{
|
|
Surface: surfaceID,
|
|
Section: section,
|
|
User: user,
|
|
Data: data,
|
|
Manifest: e.GetSurface(surfaceID),
|
|
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
|
ExtensionSurfaces: e.extensionNavItems(),
|
|
BrowserExtensions: e.browserExtensionIDs(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
|
|
// No restart required after installing a surface via admin API.
|
|
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
slug := c.Param("slug")
|
|
if slug == "" {
|
|
c.String(http.StatusNotFound, "Surface not found")
|
|
return
|
|
}
|
|
|
|
if e.stores.Packages == nil {
|
|
c.String(http.StatusNotFound, "Surface registry not available")
|
|
return
|
|
}
|
|
|
|
// Look up by ID (slug == surface ID)
|
|
sr, err := e.stores.Packages.Get(c.Request.Context(), slug)
|
|
if err != nil || sr == nil {
|
|
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
|
return
|
|
}
|
|
if !sr.Enabled {
|
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
|
|
return
|
|
}
|
|
if sr.Source == "core" {
|
|
// Core surfaces have their own routes — don't serve them here
|
|
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
|
return
|
|
}
|
|
|
|
user := e.getUserContext(c)
|
|
|
|
// Build manifest from DB record
|
|
route, _ := sr.Manifest["route"].(string)
|
|
if route == "" {
|
|
route = "/s/" + sr.ID
|
|
}
|
|
layout, _ := sr.Manifest["layout"].(string)
|
|
if layout == "" {
|
|
layout = "single"
|
|
}
|
|
manifest := &SurfaceManifest{
|
|
ID: sr.ID,
|
|
Route: route,
|
|
Title: sr.Title,
|
|
Template: "surface-extension",
|
|
Layout: layout,
|
|
Source: "extension",
|
|
}
|
|
|
|
e.Render(c, "base.html", PageData{
|
|
Surface: sr.ID,
|
|
User: user,
|
|
Manifest: manifest,
|
|
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
|
ExtensionSurfaces: e.extensionNavItems(),
|
|
BrowserExtensions: e.browserExtensionIDs(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// PageRouteMiddleware holds middleware handlers for each auth level.
|
|
// Passed to RegisterPageRoutes by main.go.
|
|
type PageRouteMiddleware struct {
|
|
Authenticated gin.HandlerFunc // AuthOrRedirect — for authenticated surfaces
|
|
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
|
|
Session gin.HandlerFunc // AuthOrSession — for workflow
|
|
}
|
|
|
|
// RegisterPageRoutes generates Gin routes from the registered surface manifests.
|
|
// Replaces manual per-surface route blocks in main.go.
|
|
//
|
|
// Groups surfaces by auth level to avoid redundant middleware chains.
|
|
// Workflow surfaces use RenderWorkflow() (separate template + data model).
|
|
// All other surfaces use RenderSurface().
|
|
func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddleware) {
|
|
// Group surfaces by auth type
|
|
byAuth := map[string][]SurfaceManifest{}
|
|
for _, s := range e.surfaces {
|
|
byAuth[s.Auth] = append(byAuth[s.Auth], s)
|
|
}
|
|
|
|
// Authenticated surfaces — single middleware group
|
|
if surfaces, ok := byAuth["authenticated"]; ok && mw.Authenticated != nil {
|
|
group := base.Group("")
|
|
group.Use(mw.Authenticated)
|
|
for _, s := range surfaces {
|
|
if !e.IsSurfaceEnabled(s.ID) {
|
|
// Disabled surface → redirect to chat
|
|
registerRoutes(group, s, e.disabledRedirect())
|
|
continue
|
|
}
|
|
handler := e.RenderSurface(s.ID)
|
|
registerRoutes(group, s, handler)
|
|
}
|
|
|
|
// No restart needed after installing new surfaces via admin API.
|
|
group.GET("/s/:slug", e.RenderExtensionSurface())
|
|
}
|
|
|
|
// Admin surfaces — auth + admin role middleware
|
|
if surfaces, ok := byAuth["admin"]; ok && len(mw.Admin) > 0 {
|
|
for _, s := range surfaces {
|
|
prefix := routePrefix(s.Route)
|
|
group := base.Group(prefix)
|
|
for _, m := range mw.Admin {
|
|
group.Use(m)
|
|
}
|
|
// Admin surfaces are always enabled (needed to manage other surfaces)
|
|
handler := e.RenderSurface(s.ID)
|
|
registerRoutesRelative(group, prefix, s, handler)
|
|
}
|
|
}
|
|
|
|
// Session surfaces (workflow) — session middleware
|
|
if surfaces, ok := byAuth["session"]; ok && mw.Session != nil {
|
|
for _, s := range surfaces {
|
|
if !e.IsSurfaceEnabled(s.ID) {
|
|
prefix := routePrefix(s.Route)
|
|
group := base.Group(prefix)
|
|
group.Use(mw.Session)
|
|
registerRoutesRelative(group, prefix, s, e.disabledRedirect())
|
|
continue
|
|
}
|
|
prefix := routePrefix(s.Route)
|
|
group := base.Group(prefix)
|
|
group.Use(mw.Session)
|
|
handler := e.surfaceHandler(s)
|
|
registerRoutesRelative(group, prefix, s, handler)
|
|
}
|
|
}
|
|
|
|
// Public surfaces — no middleware
|
|
if surfaces, ok := byAuth["public"]; ok {
|
|
for _, s := range surfaces {
|
|
if !e.IsSurfaceEnabled(s.ID) {
|
|
registerRoutes(base, s, e.disabledRedirect())
|
|
continue
|
|
}
|
|
handler := e.surfaceHandler(s)
|
|
registerRoutes(base, s, handler)
|
|
}
|
|
}
|
|
|
|
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
|
|
}
|
|
|
|
// surfaceHandler returns the appropriate Gin handler for a surface.
|
|
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
|
if s.ID == "workflow" {
|
|
return e.RenderWorkflow()
|
|
}
|
|
if s.ID == "workflow-landing" {
|
|
return e.RenderWorkflowLanding()
|
|
}
|
|
return e.RenderSurface(s.ID)
|
|
}
|
|
|
|
// registerRoutes adds the primary route and alt routes for a surface.
|
|
// Used for surfaces in the root group (authenticated).
|
|
func registerRoutes(group *gin.RouterGroup, s SurfaceManifest, handler gin.HandlerFunc) {
|
|
group.GET(s.Route, handler)
|
|
for _, alt := range s.AltRoutes {
|
|
if alt != s.Route {
|
|
group.GET(alt, handler)
|
|
}
|
|
}
|
|
}
|
|
|
|
// registerRoutesRelative adds routes relative to a group prefix.
|
|
// Used for admin/session surfaces with their own prefix group.
|
|
// "/admin/:section" with prefix "/admin" → "/:section"
|
|
func registerRoutesRelative(group *gin.RouterGroup, prefix string, s SurfaceManifest, handler gin.HandlerFunc) {
|
|
rel := stripPrefix(s.Route, prefix)
|
|
group.GET(rel, handler)
|
|
for _, alt := range s.AltRoutes {
|
|
r := stripPrefix(alt, prefix)
|
|
if r != rel {
|
|
group.GET(r, handler)
|
|
}
|
|
}
|
|
}
|
|
|
|
// routePrefix extracts the first path segment as a group prefix.
|
|
// "/admin/:section" → "/admin", "/w/:id" → "/w", "/" → ""
|
|
func routePrefix(route string) string {
|
|
trimmed := strings.TrimPrefix(route, "/")
|
|
parts := strings.SplitN(trimmed, "/", 2)
|
|
if len(parts) == 0 || parts[0] == "" || strings.HasPrefix(parts[0], ":") {
|
|
return ""
|
|
}
|
|
return "/" + parts[0]
|
|
}
|
|
|
|
// stripPrefix removes a prefix from a route path. Returns "" if the
|
|
// entire path IS the prefix (which Gin interprets as the group root).
|
|
func stripPrefix(route, prefix string) string {
|
|
if prefix == "" {
|
|
return route
|
|
}
|
|
rel := strings.TrimPrefix(route, prefix)
|
|
if rel == "" {
|
|
rel = ""
|
|
}
|
|
return rel
|
|
}
|
|
|
|
// RenderLogin serves the standalone login page.
|
|
func (e *Engine) RenderLogin() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
instanceName, logoURL, tagline := e.loadBranding()
|
|
regOpen := e.isRegistrationOpen()
|
|
|
|
e.Render(c, "login.html", PageData{
|
|
Surface: "login",
|
|
InstanceName: instanceName,
|
|
LogoURL: logoURL,
|
|
Tagline: tagline,
|
|
RegistrationOpen: regOpen,
|
|
AuthMode: e.cfg.AuthMode,
|
|
})
|
|
}
|
|
}
|
|
|
|
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
|
|
type WorkflowPageData struct {
|
|
ChannelID string
|
|
ChannelTitle string
|
|
ChannelDescription string
|
|
SessionID string
|
|
SessionName string
|
|
StageMode string // custom | form_only | form_chat | review
|
|
StageName string
|
|
FormTemplateJSON string // typed form template JSON (empty if custom)
|
|
TotalStages int
|
|
CurrentStage int
|
|
SurfacePkgID string
|
|
BrandingJSON string
|
|
}
|
|
|
|
// WorkflowLandingPageData is passed to workflow-landing.html.
|
|
type WorkflowLandingPageData struct {
|
|
WorkflowID string
|
|
Scope string
|
|
Slug string
|
|
Name string
|
|
Description string
|
|
Branding struct {
|
|
AccentColor string `json:"accent_color"`
|
|
LogoURL string `json:"logo_url"`
|
|
Tagline string `json:"tagline"`
|
|
}
|
|
PersonaName string
|
|
PersonaIcon string
|
|
StageCount int
|
|
FirstStageMode string // custom | form_only | form_chat
|
|
ResumeURL string // non-empty if visitor has an active session
|
|
}
|
|
|
|
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
|
|
// The middleware (AuthOrSession) has already created/resumed the session.
|
|
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
channelID := c.GetString("channel_id")
|
|
sessionID := c.GetString("session_id")
|
|
|
|
// Load channel metadata
|
|
var title, description string
|
|
if title == "" {
|
|
title = "Workflow"
|
|
}
|
|
|
|
// Load session display name
|
|
sessionName := "Visitor"
|
|
|
|
// Load workflow stage info for form rendering
|
|
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
|
|
var totalStages, currentStage int
|
|
stageMode = "custom" // default
|
|
|
|
instanceName, _, _ := e.loadBranding()
|
|
|
|
e.Render(c, "workflow.html", PageData{
|
|
Surface: "workflow",
|
|
InstanceName: instanceName,
|
|
Data: WorkflowPageData{
|
|
ChannelID: channelID,
|
|
ChannelTitle: title,
|
|
ChannelDescription: description,
|
|
SessionID: sessionID,
|
|
SessionName: sessionName,
|
|
StageMode: stageMode,
|
|
StageName: stageName,
|
|
FormTemplateJSON: formTplJSON,
|
|
TotalStages: totalStages,
|
|
CurrentStage: currentStage,
|
|
SurfacePkgID: surfacePkgID,
|
|
BrandingJSON: brandingJSON,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// RenderWorkflowLanding renders the public landing page for a workflow.
|
|
// Visitors see the workflow name, description, persona info, and a Start button.
|
|
func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
// Route: /w/:id/:slug — :id is scope (team-id or "global"),
|
|
// :slug is the workflow slug. Same :id param name as /w/:id
|
|
// (chat surface) to avoid Gin wildcard conflicts.
|
|
scope := c.Param("id")
|
|
slug := c.Param("slug")
|
|
|
|
// Resolve scope to team_id
|
|
var teamID *string
|
|
if scope != "global" {
|
|
teamID = &scope
|
|
}
|
|
|
|
if e.stores.Workflows == nil {
|
|
c.String(http.StatusNotFound, "Workflows not available")
|
|
return
|
|
}
|
|
|
|
wf, err := e.stores.Workflows.GetBySlug(ctx, teamID, slug)
|
|
if err != nil || wf == nil {
|
|
c.String(http.StatusNotFound, "Workflow not found")
|
|
return
|
|
}
|
|
if !wf.IsActive {
|
|
c.String(http.StatusNotFound, "Workflow not available")
|
|
return
|
|
}
|
|
|
|
data := WorkflowLandingPageData{
|
|
WorkflowID: wf.ID,
|
|
Scope: scope,
|
|
Slug: slug,
|
|
Name: wf.Name,
|
|
Description: wf.Description,
|
|
}
|
|
|
|
// Parse branding
|
|
if len(wf.Branding) > 0 {
|
|
_ = json.Unmarshal(wf.Branding, &data.Branding)
|
|
}
|
|
|
|
// Load stages for count + first persona info + stage mode
|
|
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
|
|
data.StageCount = len(stages)
|
|
if len(stages) > 0 {
|
|
data.FirstStageMode = stages[0].StageMode
|
|
if data.FirstStageMode == "" {
|
|
data.FirstStageMode = "custom"
|
|
}
|
|
// Persona lookup deferred — personas are extensions now
|
|
}
|
|
|
|
instanceName, _, _ := e.loadBranding()
|
|
|
|
e.Render(c, "workflow-landing.html", PageData{
|
|
Surface: "workflow-landing",
|
|
InstanceName: instanceName,
|
|
Data: data,
|
|
})
|
|
}
|
|
}
|
|
|
|
// getUserContext extracts user info from gin context (set by auth middleware)
|
|
// and enriches it with display name, username, and email from the database.
|
|
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
return nil
|
|
}
|
|
uid := userID.(string)
|
|
uc := &UserContext{
|
|
ID: uid,
|
|
Email: c.GetString("email"),
|
|
Role: c.GetString("role"),
|
|
}
|
|
// Enrich from DB — username, display_name, and email may not be in JWT claims.
|
|
if e.stores.Users != nil {
|
|
if u, err := e.stores.Users.GetByID(c.Request.Context(), uid); err == nil && u != nil {
|
|
uc.Username = u.Username
|
|
uc.DisplayName = u.DisplayName
|
|
if uc.Email == "" {
|
|
uc.Email = u.Email
|
|
}
|
|
}
|
|
}
|
|
return uc
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// loadMessage reads dismissible message bar config from global settings.
|
|
func (e *Engine) loadMessage() MessageConfig {
|
|
if e.stores.GlobalConfig == nil {
|
|
return MessageConfig{}
|
|
}
|
|
raw, err := e.stores.GlobalConfig.Get(context.Background(), "message")
|
|
if err != nil || raw == nil {
|
|
return MessageConfig{}
|
|
}
|
|
m := MessageConfig{}
|
|
if v, ok := raw["enabled"].(bool); ok {
|
|
m.Visible = v
|
|
}
|
|
if v, ok := raw["text"].(string); ok {
|
|
m.Text = v
|
|
}
|
|
if v, ok := raw["variant"].(string); ok {
|
|
m.Variant = v
|
|
}
|
|
if m.Variant == "" {
|
|
m.Variant = "info"
|
|
}
|
|
return m
|
|
}
|
|
|
|
// loadFooter reads optional footer bar config from global settings.
|
|
func (e *Engine) loadFooter() FooterConfig {
|
|
if e.stores.GlobalConfig == nil {
|
|
return FooterConfig{}
|
|
}
|
|
raw, err := e.stores.GlobalConfig.Get(context.Background(), "footer")
|
|
if err != nil || raw == nil {
|
|
return FooterConfig{}
|
|
}
|
|
f := FooterConfig{}
|
|
if v, ok := raw["enabled"].(bool); ok {
|
|
f.Visible = v
|
|
}
|
|
if v, ok := raw["text"].(string); ok {
|
|
f.Text = v
|
|
}
|
|
return f
|
|
}
|
|
|
|
// loadBranding reads instance branding from global settings.
|
|
func (e *Engine) loadBranding() (name, logoURL, tagline string) {
|
|
name = "Switchboard Core" // 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
|
|
}
|
|
|
|
// 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)
|
|
}
|