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:
Edric Li
2026-01-10 18:37:44 +08:00
parent e83f644c3f
commit 5265b12cc7
24 changed files with 533 additions and 40 deletions

View File

@@ -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.")

View File

@@ -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 {

View 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]) + `"`
}