Changeset 0.25.0 (#160)
This commit is contained in:
@@ -39,9 +39,28 @@ type Engine struct {
|
||||
cfg *config.Config
|
||||
stores store.Stores
|
||||
loaders map[string]DataLoaderFunc
|
||||
surfaces []SurfaceManifest // v0.25.0: registered surface definitions
|
||||
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: "chat", "editor", "my-dashboard"
|
||||
Route string `json:"route"` // primary URL pattern: "/", "/editor/:wsId"
|
||||
AltRoutes []string `json:"alt_routes"` // additional URL patterns: ["/chat/:chatID"]
|
||||
Title string `json:"title"` // human-readable: "Chat", "Editor"
|
||||
Template string `json:"template"` // Go template name: "surface-chat", "surface-editor"
|
||||
Components []string `json:"components"` // component IDs used: ["chat-pane", "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"`
|
||||
@@ -66,6 +85,12 @@ type PageData struct {
|
||||
Theme string // "dark", "light", or "" (= use default)
|
||||
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
||||
|
||||
// v0.25.0: Surface manifest — layout preset, components, etc.
|
||||
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
||||
|
||||
// v0.25.0: Enabled surface IDs for conditional nav rendering.
|
||||
EnabledSurfaces []string `json:"-"`
|
||||
|
||||
// v0.22.7: Login/splash page fields
|
||||
InstanceName string // branding: instance display name
|
||||
LogoURL string // branding: custom logo URL
|
||||
@@ -74,6 +99,17 @@ type PageData struct {
|
||||
AuthMode string // v0.24.1: "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
|
||||
}
|
||||
|
||||
// UserContext is the authenticated user's info available to templates.
|
||||
type UserContext struct {
|
||||
ID string `json:"id"`
|
||||
@@ -96,9 +132,74 @@ func New(cfg *config.Config, stores store.Stores) *Engine {
|
||||
}
|
||||
e.parseTemplates()
|
||||
e.registerLoaders()
|
||||
e.registerCoreSurfaces()
|
||||
e.SeedSurfaces() // v0.25.0: Write core manifests to DB (preserves admin toggle state)
|
||||
return e
|
||||
}
|
||||
|
||||
// registerCoreSurfaces populates the surface manifest list with the five
|
||||
// core surfaces. Extension surfaces will be appended at startup from
|
||||
// extension manifests (future).
|
||||
func (e *Engine) registerCoreSurfaces() {
|
||||
e.surfaces = []SurfaceManifest{
|
||||
{
|
||||
ID: "chat", Route: "/", AltRoutes: []string{"/chat/:chatID"},
|
||||
Title: "Chat", Template: "surface-chat", Auth: "authenticated",
|
||||
Components: []string{"chat-pane", "model-selector", "user-menu"},
|
||||
DataRequires: []string{"chat"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "editor", Route: "/editor/:wsId", AltRoutes: []string{"/editor"},
|
||||
Title: "Editor", Template: "surface-editor", Auth: "authenticated",
|
||||
Components: []string{"file-tree", "code-editor", "chat-pane", "note-editor", "model-selector"},
|
||||
DataRequires: []string{"editor"},
|
||||
Layout: "editor", Source: "extension", // Dogfood: editor is the first non-core surface
|
||||
},
|
||||
{
|
||||
ID: "notes", Route: "/notes", AltRoutes: []string{"/notes/:noteId"},
|
||||
Title: "Notes", Template: "surface-notes", Auth: "authenticated",
|
||||
Components: []string{"note-editor", "chat-pane"},
|
||||
DataRequires: []string{"notes"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
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: "workflow", Route: "/w/:id",
|
||||
Title: "Workflow", Template: "workflow", Auth: "session",
|
||||
Components: []string{"chat-pane"},
|
||||
DataRequires: []string{"workflow"},
|
||||
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{
|
||||
@@ -191,14 +292,156 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
e.Render(c, "base.html", PageData{
|
||||
Surface: surfaceID,
|
||||
Section: section,
|
||||
User: user,
|
||||
Data: data,
|
||||
Surface: surfaceID,
|
||||
Section: section,
|
||||
User: user,
|
||||
Data: data,
|
||||
Manifest: e.GetSurface(surfaceID),
|
||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PageRouteMiddleware holds middleware handlers for each auth level.
|
||||
// Passed to RegisterPageRoutes by main.go.
|
||||
type PageRouteMiddleware struct {
|
||||
Authenticated gin.HandlerFunc // AuthOrRedirect — for chat, editor, notes, settings
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.RenderSurface(s.ID)
|
||||
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()
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user