//go:build embed package web import ( "bytes" "context" "embed" "encoding/json" "io" "io/fs" "net/http" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/gin-gonic/gin" ) const ( // NonceHTMLPlaceholder is the placeholder for nonce in HTML script tags NonceHTMLPlaceholder = "__CSP_NONCE_VALUE__" ) //go:embed all:dist var frontendFS embed.FS // PublicSettingsProvider is an interface to fetch public settings type PublicSettingsProvider interface { GetPublicSettingsForInjection(ctx context.Context) (any, 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 shouldBypassEmbeddedFrontend(path) { 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) { // Get nonce from context (generated by SecurityHeaders middleware) nonce := middleware.GetNonceFromContext(c) // 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 } // Replace nonce placeholder with actual nonce before serving content := replaceNoncePlaceholder(cached.Content, nonce) c.Header("ETag", cached.ETag) c.Header("Cache-Control", "no-cache") // Must revalidate c.Data(http.StatusOK, "text/html; charset=utf-8", 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) // Replace nonce placeholder with actual nonce before serving content := replaceNoncePlaceholder(rendered, nonce) 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", content) c.Abort() } func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte { // Create the script tag to inject with nonce placeholder // The placeholder will be replaced with actual nonce at request time script := []byte(``) // Inject before headClose := []byte("") result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1) // Replace