| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- //go:build embed
- package web
- import (
- "bytes"
- "context"
- "embed"
- "encoding/json"
- htmlpkg "html"
- "io"
- "io/fs"
- "net/http"
- "net/url"
- "os"
- "path/filepath"
- "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
- overrideDir string // local file override directory
- }
- // 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,
- overrideDir: filepath.Join("data", "public"),
- }, 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
- }
- // Try local override first
- if s.tryServeOverride(c, cleanPath) {
- return
- }
- // Serve static files normally (hashed assets get long-lived cache headers)
- applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath)
- 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
- }
- // tryServeOverride checks if a local override file exists and serves it.
- // Files in overrideDir take precedence over embedded files.
- func (s *FrontendServer) tryServeOverride(c *gin.Context, cleanPath string) bool {
- if s.overrideDir == "" {
- return false
- }
- filePath := filepath.Join(s.overrideDir, filepath.Clean("/"+cleanPath))
- info, err := os.Stat(filePath)
- if err != nil || info.IsDir() {
- return false
- }
- c.File(filePath)
- c.Abort()
- 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(`<script nonce="` + NonceHTMLPlaceholder + `">window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
- // Inject before </head>
- headClose := []byte("</head>")
- result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
- // Apply custom branding before the browser paints the static defaults.
- result = injectSiteTitle(result, settingsJSON)
- result = injectSiteFavicon(result, settingsJSON)
- return result
- }
- // injectSiteFavicon replaces the static favicon with a configured, browser-safe image URL.
- func injectSiteFavicon(html, settingsJSON []byte) []byte {
- var cfg struct {
- SiteLogo string `json:"site_logo"`
- }
- if err := json.Unmarshal(settingsJSON, &cfg); err != nil {
- return html
- }
- logoURL := safeImageURL(cfg.SiteLogo)
- if logoURL == "" {
- return html
- }
- linkStart := bytes.Index(html, []byte(`<link rel="icon"`))
- if linkStart == -1 {
- return html
- }
- linkEndOffset := bytes.IndexByte(html[linkStart:], '>')
- if linkEndOffset == -1 {
- return html
- }
- linkEnd := linkStart + linkEndOffset + 1
- replacement := []byte(`<link rel="icon" href="` + htmlpkg.EscapeString(logoURL) + `" />`)
- var buf bytes.Buffer
- buf.Write(html[:linkStart])
- buf.Write(replacement)
- buf.Write(html[linkEnd:])
- return buf.Bytes()
- }
- func safeImageURL(value string) string {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" {
- return ""
- }
- if strings.HasPrefix(trimmed, "/") && !strings.HasPrefix(trimmed, "//") {
- return trimmed
- }
- if strings.HasPrefix(strings.ToLower(trimmed), "data:image/") {
- return trimmed
- }
- parsed, err := url.Parse(trimmed)
- if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
- return ""
- }
- return trimmed
- }
- // injectSiteTitle replaces the static <title> in HTML with the configured site name.
- // This ensures the browser tab shows the correct title before JS executes.
- func injectSiteTitle(html, settingsJSON []byte) []byte {
- var cfg struct {
- SiteName string `json:"site_name"`
- }
- if err := json.Unmarshal(settingsJSON, &cfg); err != nil || cfg.SiteName == "" {
- return html
- }
- // Find and replace the existing <title>...</title>
- titleStart := bytes.Index(html, []byte("<title>"))
- titleEnd := bytes.Index(html, []byte("</title>"))
- if titleStart == -1 || titleEnd == -1 || titleEnd <= titleStart {
- return html
- }
- newTitle := []byte("<title>" + htmlpkg.EscapeString(cfg.SiteName) + " - AI API Gateway</title>")
- var buf bytes.Buffer
- buf.Write(html[:titleStart])
- buf.Write(newTitle)
- buf.Write(html[titleEnd+len("</title>"):])
- return buf.Bytes()
- }
- // replaceNoncePlaceholder replaces the nonce placeholder with actual nonce value
- func replaceNoncePlaceholder(html []byte, nonce string) []byte {
- return bytes.ReplaceAll(html, []byte(NonceHTMLPlaceholder), []byte(nonce))
- }
- // 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))
- overrideDir := filepath.Join("data", "public")
- return func(c *gin.Context) {
- path := c.Request.URL.Path
- if shouldBypassEmbeddedFrontend(path) {
- c.Next()
- return
- }
- cleanPath := strings.TrimPrefix(path, "/")
- if cleanPath == "" {
- cleanPath = "index.html"
- }
- if file, err := distFS.Open(cleanPath); err == nil {
- _ = file.Close()
- // Try local override first
- if tryServeOverrideFile(c, overrideDir, cleanPath) {
- return
- }
- applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath)
- fileServer.ServeHTTP(c.Writer, c.Request)
- c.Abort()
- return
- }
- serveIndexHTML(c, distFS)
- }
- }
- // tryServeOverrideFile is a standalone version of tryServeOverride for legacy usage.
- func tryServeOverrideFile(c *gin.Context, overrideDir, cleanPath string) bool {
- if overrideDir == "" {
- return false
- }
- filePath := filepath.Join(overrideDir, filepath.Clean("/"+cleanPath))
- info, err := os.Stat(filePath)
- if err != nil || info.IsDir() {
- return false
- }
- c.File(filePath)
- c.Abort()
- return true
- }
- func shouldBypassEmbeddedFrontend(path string) bool {
- trimmed := strings.TrimSpace(path)
- return strings.HasPrefix(trimmed, "/api/") ||
- strings.HasPrefix(trimmed, "/v1/") ||
- strings.HasPrefix(trimmed, "/v1beta/") ||
- strings.HasPrefix(trimmed, "/backend-api/") ||
- strings.HasPrefix(trimmed, "/antigravity/") ||
- strings.HasPrefix(trimmed, "/setup/") ||
- trimmed == "/health" ||
- trimmed == "/models" ||
- trimmed == "/responses" ||
- strings.HasPrefix(trimmed, "/responses/") ||
- trimmed == "/alpha/search" ||
- strings.HasPrefix(trimmed, "/images/") ||
- strings.HasPrefix(trimmed, "/videos/")
- }
- 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
- }
|