feat(settings): add home content customization and config injection
- Add home_content setting for custom homepage (HTML or iframe URL) - Inject public settings into index.html to eliminate page flash - Support ETag caching with automatic invalidation on settings update - Add Vite plugin for dev mode settings injection - Refactor HomeView to use appStore instead of local API calls
This commit is contained in:
@@ -4,11 +4,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PublicSettingsProvider is an interface to fetch public settings
|
||||
// This stub is needed for compilation when frontend is not embedded
|
||||
type PublicSettingsProvider interface {
|
||||
GetPublicSettingsForInjection(ctx context.Context) (interface{}, error)
|
||||
}
|
||||
|
||||
// FrontendServer is a stub for non-embed builds
|
||||
type FrontendServer struct{}
|
||||
|
||||
// NewFrontendServer returns an error when frontend is not embedded
|
||||
func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) {
|
||||
return nil, errors.New("frontend not embedded")
|
||||
}
|
||||
|
||||
// InvalidateCache is a no-op for non-embed builds
|
||||
func (s *FrontendServer) InvalidateCache() {}
|
||||
|
||||
// Middleware returns a handler that returns 404 for non-embed builds
|
||||
func (s *FrontendServer) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func ServeEmbeddedFrontend() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.")
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -15,6 +19,162 @@ import (
|
||||
//go:embed all:dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
// PublicSettingsProvider is an interface to fetch public settings
|
||||
type PublicSettingsProvider interface {
|
||||
GetPublicSettingsForInjection(ctx context.Context) (interface{}, error)
|
||||
}
|
||||
|
||||
// FrontendServer serves the embedded frontend with settings injection
|
||||
type FrontendServer struct {
|
||||
distFS fs.FS
|
||||
fileServer http.Handler
|
||||
baseHTML []byte
|
||||
cache *HTMLCache
|
||||
settings PublicSettingsProvider
|
||||
}
|
||||
|
||||
// NewFrontendServer creates a new frontend server with settings injection
|
||||
func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read base HTML once
|
||||
file, err := distFS.Open("index.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
baseHTML, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML(baseHTML)
|
||||
|
||||
return &FrontendServer{
|
||||
distFS: distFS,
|
||||
fileServer: http.FileServer(http.FS(distFS)),
|
||||
baseHTML: baseHTML,
|
||||
cache: cache,
|
||||
settings: settingsProvider,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InvalidateCache invalidates the HTML cache (call when settings change)
|
||||
func (s *FrontendServer) InvalidateCache() {
|
||||
if s != nil && s.cache != nil {
|
||||
s.cache.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware returns the Gin middleware handler
|
||||
func (s *FrontendServer) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Skip API routes
|
||||
if strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/v1/") ||
|
||||
strings.HasPrefix(path, "/v1beta/") ||
|
||||
strings.HasPrefix(path, "/antigravity/") ||
|
||||
strings.HasPrefix(path, "/setup/") ||
|
||||
path == "/health" ||
|
||||
path == "/responses" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cleanPath := strings.TrimPrefix(path, "/")
|
||||
if cleanPath == "" {
|
||||
cleanPath = "index.html"
|
||||
}
|
||||
|
||||
// For index.html or SPA routes, serve with injected settings
|
||||
if cleanPath == "index.html" || !s.fileExists(cleanPath) {
|
||||
s.serveIndexHTML(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve static files normally
|
||||
s.fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FrontendServer) fileExists(path string) bool {
|
||||
file, err := s.distFS.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = file.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
|
||||
// Check cache first
|
||||
cached := s.cache.Get()
|
||||
if cached != nil {
|
||||
// Check If-None-Match for 304 response
|
||||
if match := c.GetHeader("If-None-Match"); match == cached.ETag {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("ETag", cached.ETag)
|
||||
c.Header("Cache-Control", "no-cache") // Must revalidate
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", cached.Content)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss - fetch settings and render
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
settings, err := s.settings.GetPublicSettingsForInjection(ctx)
|
||||
if err != nil {
|
||||
// Fallback: serve without injection
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
// Fallback: serve without injection
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
rendered := s.injectSettings(settingsJSON)
|
||||
s.cache.Set(rendered, settingsJSON)
|
||||
|
||||
cached = s.cache.Get()
|
||||
if cached != nil {
|
||||
c.Header("ETag", cached.ETag)
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", rendered)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte {
|
||||
// Create the script tag to inject
|
||||
script := []byte(`<script>window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
|
||||
|
||||
// Inject before </head>
|
||||
headClose := []byte("</head>")
|
||||
return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
|
||||
}
|
||||
|
||||
// ServeEmbeddedFrontend returns a middleware for serving embedded frontend
|
||||
// This is the legacy function for backward compatibility when no settings provider is available
|
||||
func ServeEmbeddedFrontend() gin.HandlerFunc {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
|
||||
77
backend/internal/web/html_cache.go
Normal file
77
backend/internal/web/html_cache.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//go:build embed
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HTMLCache manages the cached index.html with injected settings
|
||||
type HTMLCache struct {
|
||||
mu sync.RWMutex
|
||||
cachedHTML []byte
|
||||
etag string
|
||||
baseHTMLHash string // Hash of the original index.html (immutable after build)
|
||||
settingsVersion uint64 // Incremented when settings change
|
||||
}
|
||||
|
||||
// CachedHTML represents the cache state
|
||||
type CachedHTML struct {
|
||||
Content []byte
|
||||
ETag string
|
||||
}
|
||||
|
||||
// NewHTMLCache creates a new HTML cache instance
|
||||
func NewHTMLCache() *HTMLCache {
|
||||
return &HTMLCache{}
|
||||
}
|
||||
|
||||
// SetBaseHTML initializes the cache with the base HTML template
|
||||
func (c *HTMLCache) SetBaseHTML(baseHTML []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
hash := sha256.Sum256(baseHTML)
|
||||
c.baseHTMLHash = hex.EncodeToString(hash[:8]) // First 8 bytes for brevity
|
||||
}
|
||||
|
||||
// Invalidate marks the cache as stale
|
||||
func (c *HTMLCache) Invalidate() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.settingsVersion++
|
||||
c.cachedHTML = nil
|
||||
c.etag = ""
|
||||
}
|
||||
|
||||
// Get returns the cached HTML or nil if cache is stale
|
||||
func (c *HTMLCache) Get() *CachedHTML {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.cachedHTML == nil {
|
||||
return nil
|
||||
}
|
||||
return &CachedHTML{
|
||||
Content: c.cachedHTML,
|
||||
ETag: c.etag,
|
||||
}
|
||||
}
|
||||
|
||||
// Set updates the cache with new rendered HTML
|
||||
func (c *HTMLCache) Set(html []byte, settingsJSON []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cachedHTML = html
|
||||
c.etag = c.generateETag(settingsJSON)
|
||||
}
|
||||
|
||||
// generateETag creates an ETag from base HTML hash + settings hash
|
||||
func (c *HTMLCache) generateETag(settingsJSON []byte) string {
|
||||
settingsHash := sha256.Sum256(settingsJSON)
|
||||
return `"` + c.baseHTMLHash + "-" + hex.EncodeToString(settingsHash[:8]) + `"`
|
||||
}
|
||||
Reference in New Issue
Block a user