Files
sub2api/backend/internal/web/embed_on.go
Edric Li 5265b12cc7 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
2026-01-10 18:37:44 +08:00

239 lines
5.5 KiB
Go

//go:build embed
package web
import (
"bytes"
"context"
"embed"
"encoding/json"
"io"
"io/fs"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
//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 {
panic("failed to get dist subdirectory: " + err.Error())
}
fileServer := http.FileServer(http.FS(distFS))
return func(c *gin.Context) {
path := c.Request.URL.Path
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"
}
if file, err := distFS.Open(cleanPath); err == nil {
_ = file.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
return
}
serveIndexHTML(c, distFS)
}
}
func serveIndexHTML(c *gin.Context, fsys fs.FS) {
file, err := fsys.Open("index.html")
if err != nil {
c.String(http.StatusNotFound, "Frontend not found")
c.Abort()
return
}
defer func() { _ = file.Close() }()
content, err := io.ReadAll(file)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to read index.html")
c.Abort()
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
c.Abort()
}
func HasEmbeddedFrontend() bool {
_, err := frontendFS.ReadFile("dist/index.html")
return err == nil
}