All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / test-sqlite (push) Successful in 2m59s
CI/CD / build-and-deploy (push) Successful in 1m28s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
1349 lines
39 KiB
Go
1349 lines
39 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"
|
|
|
|
"armature/config"
|
|
"armature/models"
|
|
"armature/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"`
|
|
SurfacePath string `json:"-"` // matched surface path pattern, e.g. "/monitor"
|
|
SurfaceParams map[string]string `json:"-"` // extracted params, e.g. {"id": "abc"}
|
|
|
|
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 || sr.Status != "active" {
|
|
continue
|
|
}
|
|
// Only surface and full types have routes — extensions are headless
|
|
if sr.Type != "surface" && sr.Type != "full" {
|
|
continue
|
|
}
|
|
|
|
basePath := "/s/" + sr.ID
|
|
title := sr.Title
|
|
|
|
// Multi-surface: find the nav entry from surfaces array
|
|
if sr.Manifest != nil {
|
|
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok {
|
|
navEntry := findNavSurface(rawSurfaces)
|
|
if navEntry != nil {
|
|
if p, ok := navEntry["path"].(string); ok && p != "/" {
|
|
basePath = "/s/" + sr.ID + p
|
|
}
|
|
if t, ok := navEntry["title"].(string); ok && t != "" {
|
|
title = t
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
items = append(items, ExtensionNavItem{
|
|
ID: sr.ID,
|
|
Title: title,
|
|
Route: basePath,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
// findNavSurface returns the surface entry to use for navigation.
|
|
// Returns the first entry with nav:true, falling back to the entry with path "/".
|
|
func findNavSurface(surfaces []any) map[string]any {
|
|
var root map[string]any
|
|
for _, raw := range surfaces {
|
|
s, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if nav, ok := s["nav"].(bool); ok && nav {
|
|
return s
|
|
}
|
|
if p, ok := s["path"].(string); ok && p == "/" {
|
|
root = s
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
// 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 extension surface rendering with multi-surface
|
|
// support. Each surface entry in the manifest can have its own path, access
|
|
// level, title, and layout. Called by RegisterExtensionRoutes for both root
|
|
// requests (/s/:slug) and sub-path requests (/s/:slug/monitor).
|
|
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
|
|
}
|
|
reqPath := c.Param("path")
|
|
if reqPath == "" {
|
|
reqPath = "/"
|
|
}
|
|
|
|
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" {
|
|
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
|
return
|
|
}
|
|
|
|
// ── Early auth short-circuit ─────────────────────────────
|
|
// For all-authenticated packages, redirect before surface matching.
|
|
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 {
|
|
profile := aggregateAccess(rawSurfaces)
|
|
if profile == "authenticated" && c.GetString("user_id") == "" {
|
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
|
|
return
|
|
}
|
|
}
|
|
|
|
// ── Multi-surface resolution ─────────────────────────────
|
|
var surfacePath string
|
|
var surfaceParams map[string]string
|
|
surfaceTitle := sr.Title
|
|
surfaceAccess := "authenticated"
|
|
surfaceLayout := "single"
|
|
|
|
// Read package-level defaults
|
|
if pkgAuth, ok := sr.Manifest["auth"].(string); ok && pkgAuth != "" {
|
|
surfaceAccess = pkgAuth
|
|
}
|
|
if pkgLayout, ok := sr.Manifest["layout"].(string); ok && pkgLayout != "" {
|
|
surfaceLayout = pkgLayout
|
|
}
|
|
|
|
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 {
|
|
matched, params := matchSurface(rawSurfaces, reqPath)
|
|
if matched == nil {
|
|
c.String(http.StatusNotFound, "Page not found")
|
|
return
|
|
}
|
|
surfacePath, _ = matched["path"].(string)
|
|
surfaceParams = params
|
|
if t, ok := matched["title"].(string); ok && t != "" {
|
|
surfaceTitle = t
|
|
}
|
|
if a, ok := matched["access"].(string); ok && a != "" {
|
|
surfaceAccess = a
|
|
}
|
|
if l, ok := matched["layout"].(string); ok && l != "" {
|
|
surfaceLayout = l
|
|
}
|
|
} else {
|
|
// Legacy package without surfaces — only serve root path
|
|
if reqPath != "/" {
|
|
c.String(http.StatusNotFound, "Page not found")
|
|
return
|
|
}
|
|
surfacePath = "/"
|
|
}
|
|
|
|
// ── Access check ─────────────────────────────────────────
|
|
if !e.evaluateAccess(c, surfaceAccess) {
|
|
// Redirect unauthenticated users to login; deny others with 403
|
|
if c.GetString("user_id") == "" {
|
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
|
|
} else {
|
|
c.String(http.StatusForbidden, "Access denied")
|
|
}
|
|
return
|
|
}
|
|
|
|
user := e.getUserContext(c)
|
|
|
|
manifest := &SurfaceManifest{
|
|
ID: sr.ID,
|
|
Route: "/s/" + sr.ID,
|
|
Title: surfaceTitle,
|
|
Template: "surface-extension",
|
|
Layout: surfaceLayout,
|
|
Source: "extension",
|
|
}
|
|
|
|
e.Render(c, "base.html", PageData{
|
|
Surface: sr.ID,
|
|
User: user,
|
|
Manifest: manifest,
|
|
SurfacePath: surfacePath,
|
|
SurfaceParams: surfaceParams,
|
|
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
|
ExtensionSurfaces: e.extensionNavItems(),
|
|
BrowserExtensions: e.browserExtensionIDs(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// matchSurface finds the best-matching surface entry for a request path.
|
|
// Supports Gin-style param segments (/:id matches /abc). Static segments
|
|
// are preferred over param segments. Returns nil if no surface matches.
|
|
func matchSurface(surfaces []any, reqPath string) (map[string]any, map[string]string) {
|
|
reqParts := splitPath(reqPath)
|
|
|
|
type candidate struct {
|
|
surface map[string]any
|
|
params map[string]string
|
|
score int // higher = more static segments = better match
|
|
}
|
|
|
|
var best *candidate
|
|
for _, raw := range surfaces {
|
|
s, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
pattern, _ := s["path"].(string)
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
patParts := splitPath(pattern)
|
|
if len(patParts) != len(reqParts) {
|
|
continue
|
|
}
|
|
|
|
params := map[string]string{}
|
|
score := 0
|
|
matched := true
|
|
for i, pp := range patParts {
|
|
if strings.HasPrefix(pp, ":") {
|
|
params[pp[1:]] = reqParts[i]
|
|
} else if pp == reqParts[i] {
|
|
score++
|
|
} else {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
if best == nil || score > best.score {
|
|
best = &candidate{surface: s, params: params, score: score}
|
|
}
|
|
}
|
|
if best == nil {
|
|
return nil, nil
|
|
}
|
|
return best.surface, best.params
|
|
}
|
|
|
|
// splitPath splits a URL path into non-empty segments.
|
|
// "/" → [], "/monitor" → ["monitor"], "/monitor/:id" → ["monitor", ":id"]
|
|
func splitPath(p string) []string {
|
|
var parts []string
|
|
for _, s := range strings.Split(p, "/") {
|
|
if s != "" {
|
|
parts = append(parts, s)
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
// aggregateAccess scans surfaces to determine the package's access profile.
|
|
// Returns "public" (all public), "authenticated" (all require auth), or "mixed".
|
|
func aggregateAccess(surfaces []any) string {
|
|
hasPublic := false
|
|
hasAuth := false
|
|
for _, raw := range surfaces {
|
|
s, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
access, _ := s["access"].(string)
|
|
if access == "" {
|
|
access = "authenticated" // default
|
|
}
|
|
if access == "public" {
|
|
hasPublic = true
|
|
} else {
|
|
hasAuth = true
|
|
}
|
|
if hasPublic && hasAuth {
|
|
return "mixed"
|
|
}
|
|
}
|
|
if hasPublic {
|
|
return "public"
|
|
}
|
|
return "authenticated"
|
|
}
|
|
|
|
// evaluateAccess checks whether the current request meets an access requirement.
|
|
func (e *Engine) evaluateAccess(c *gin.Context, access string) bool {
|
|
switch {
|
|
case access == "public":
|
|
return true
|
|
case access == "authenticated":
|
|
return c.GetString("user_id") != ""
|
|
case access == "admin":
|
|
return c.GetBool("is_admin")
|
|
case strings.HasPrefix(access, "group:"):
|
|
// Group membership check — look for groups set by auth middleware
|
|
group := strings.TrimPrefix(access, "group:")
|
|
if groups, exists := c.Get("user_groups"); exists {
|
|
if gs, ok := groups.([]string); ok {
|
|
for _, g := range gs {
|
|
if g == group {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
case strings.HasPrefix(access, "role:"):
|
|
role := strings.TrimPrefix(access, "role:")
|
|
userID := c.GetString("user_id")
|
|
if userID == "" {
|
|
return false
|
|
}
|
|
if c.GetBool("is_admin") {
|
|
return true
|
|
}
|
|
if e.stores.Teams == nil {
|
|
return false
|
|
}
|
|
has, err := e.stores.Teams.HasRoleInAnyTeam(c.Request.Context(), userID, role)
|
|
return err == nil && has
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
// Welcome auto-redirects to / when extension surfaces exist
|
|
if s.ID == "welcome" {
|
|
handler = e.welcomeRedirectIfSurfaces(handler)
|
|
}
|
|
registerRoutes(group, s, handler)
|
|
}
|
|
|
|
// Extension surfaces are registered separately via
|
|
// RegisterExtensionRoutes (called from main.go) to unify
|
|
// the /s/:slug route tree with the ext API dispatcher.
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// RegisterExtensionRoutes registers the combined extension surface and ext API
|
|
// routes. A single /s/:slug/*path catch-all dispatches between:
|
|
// - /s/:slug/api/* → apiAuth middleware + extAPIHandler (JSON API)
|
|
// - /s/:slug/* → surface handler with per-surface access checks (HTML)
|
|
//
|
|
// This avoids Gin route conflicts between the catch-all and the API sub-path.
|
|
// Must be called AFTER RegisterPageRoutes (which handles core surfaces).
|
|
func (e *Engine) RegisterExtensionRoutes(
|
|
base *gin.RouterGroup,
|
|
optAuth gin.HandlerFunc,
|
|
apiAuth gin.HandlerFunc,
|
|
extAPIHandler gin.HandlerFunc,
|
|
) {
|
|
surfaceHandler := e.RenderExtensionSurface()
|
|
|
|
sGroup := base.Group("/s/:slug")
|
|
sGroup.Use(optAuth) // populate user context if token present; don't require it
|
|
|
|
// Dispatcher handles both root and sub-path requests.
|
|
// API requests (/s/:slug/api/*) go to extAPIHandler with JWT auth.
|
|
// Everything else goes to the surface handler with per-surface access checks.
|
|
dispatch := func(c *gin.Context) {
|
|
subPath := c.Param("path")
|
|
if subPath == "" {
|
|
subPath = "/"
|
|
}
|
|
|
|
// API requests: /s/:slug/api/*
|
|
if strings.HasPrefix(subPath, "/api/") || subPath == "/api" {
|
|
apiAuth(c)
|
|
if c.IsAborted() {
|
|
return
|
|
}
|
|
// Rewrite the path param so the ext API handler sees the
|
|
// API-relative path, e.g. "/api/items" → "/items"
|
|
apiPath := strings.TrimPrefix(subPath, "/api")
|
|
if apiPath == "" {
|
|
apiPath = "/"
|
|
}
|
|
for i := range c.Params {
|
|
if c.Params[i].Key == "path" {
|
|
c.Params[i].Value = apiPath
|
|
break
|
|
}
|
|
}
|
|
extAPIHandler(c)
|
|
return
|
|
}
|
|
|
|
// Surface requests: GET only
|
|
if c.Request.Method != http.MethodGet {
|
|
c.String(http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
surfaceHandler(c)
|
|
}
|
|
|
|
// Root path: /s/:slug (Gin requires a separate registration since
|
|
// /*path doesn't match the bare group path)
|
|
sGroup.GET("", func(c *gin.Context) {
|
|
c.Params = append(c.Params, gin.Param{Key: "path", Value: "/"})
|
|
dispatch(c)
|
|
})
|
|
|
|
// Sub-paths: /s/:slug/*path — API and surface dispatch
|
|
sGroup.Any("/*path", dispatch)
|
|
}
|
|
|
|
// 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 {
|
|
EntryToken string
|
|
WorkflowTitle string
|
|
WorkflowDescription string
|
|
SessionID string
|
|
SessionName string
|
|
StageMode string // form | delegated | automated
|
|
StageName string
|
|
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
|
TotalStages int
|
|
CurrentStage int
|
|
SurfacePkgID string
|
|
BrandingJSON string
|
|
InstanceID string // workflow instance ID (used for API calls)
|
|
Status string // pending | active | completed | cancelled | stale
|
|
AudienceMismatch bool // true when current stage audience is team/system but visitor is unauthenticated
|
|
}
|
|
|
|
// 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 // form | delegated | automated
|
|
ResumeURL string // non-empty if visitor has an active session
|
|
}
|
|
|
|
// RenderWorkflow renders the workflow execution page for a running instance.
|
|
// Route: /w/:id — :id is either an instance ID or a public entry token.
|
|
// The middleware (AuthOrSession) has already created/resumed the session.
|
|
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
routeID := c.Param("id")
|
|
sessionID := c.GetString("session_id")
|
|
sessionName := "Visitor"
|
|
|
|
instanceName, _, _ := e.loadBranding()
|
|
|
|
// Try to load instance — the route param may be an instance ID
|
|
// or an entry token. Also check the ?token= query param.
|
|
var inst *models.WorkflowInstance
|
|
if e.stores.Workflows != nil {
|
|
// First try: query param token (from landing page redirect)
|
|
if qToken := c.Query("token"); qToken != "" {
|
|
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, qToken)
|
|
}
|
|
// Second try: route param as entry token
|
|
if inst == nil {
|
|
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, routeID)
|
|
}
|
|
// Third try: route param as instance ID
|
|
if inst == nil {
|
|
inst, _ = e.stores.Workflows.GetInstance(ctx, routeID)
|
|
}
|
|
}
|
|
|
|
if inst == nil {
|
|
c.String(http.StatusNotFound, "Workflow instance not found")
|
|
return
|
|
}
|
|
|
|
// Load workflow definition for title, description, branding
|
|
title, description := "Workflow", ""
|
|
var brandingJSON string
|
|
wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID)
|
|
if wf != nil {
|
|
title = wf.Name
|
|
description = wf.Description
|
|
if len(wf.Branding) > 0 {
|
|
brandingJSON = string(wf.Branding)
|
|
}
|
|
}
|
|
|
|
// Load stages to resolve current stage info
|
|
var stageMode, stageName, formTplJSON, surfacePkgID string
|
|
var totalStages, currentStage int
|
|
stageMode = "form" // default
|
|
|
|
stages, _ := e.stores.Workflows.ListStages(ctx, inst.WorkflowID)
|
|
totalStages = len(stages)
|
|
for i, s := range stages {
|
|
if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
|
|
currentStage = i
|
|
stageName = s.Name
|
|
stageMode = s.StageMode
|
|
if stageMode == "" {
|
|
stageMode = "form"
|
|
}
|
|
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "null" {
|
|
formTplJSON = string(s.FormTemplate)
|
|
}
|
|
if s.SurfacePkgID != nil {
|
|
surfacePkgID = *s.SurfacePkgID
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Resolve the actual entry token from the instance (used by JS API calls)
|
|
entryToken := ""
|
|
if inst.EntryToken != nil {
|
|
entryToken = *inst.EntryToken
|
|
}
|
|
|
|
// Detect audience mismatch: stage requires team/system but visitor is
|
|
// unauthenticated. Show a "submitted" screen instead of the stage form.
|
|
audienceMismatch := false
|
|
userID := c.GetString("user_id")
|
|
if userID == "" {
|
|
// Visitor is unauthenticated — check the current stage audience
|
|
for _, s := range stages {
|
|
if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
|
|
if s.Audience == models.AudienceTeam || s.Audience == models.AudienceSystem {
|
|
audienceMismatch = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
e.Render(c, "workflow.html", PageData{
|
|
Surface: "workflow",
|
|
InstanceName: instanceName,
|
|
Data: WorkflowPageData{
|
|
EntryToken: entryToken,
|
|
WorkflowTitle: title,
|
|
WorkflowDescription: description,
|
|
SessionID: sessionID,
|
|
SessionName: sessionName,
|
|
StageMode: stageMode,
|
|
StageName: stageName,
|
|
FormTemplateJSON: formTplJSON,
|
|
TotalStages: totalStages,
|
|
CurrentStage: currentStage,
|
|
SurfacePkgID: surfacePkgID,
|
|
BrandingJSON: brandingJSON,
|
|
InstanceID: inst.ID,
|
|
Status: inst.Status,
|
|
AudienceMismatch: audienceMismatch,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 = "Armature" // 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)
|
|
}
|