| |
|
|
| 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 = "__CSP_NONCE_VALUE__" |
| ) |
|
|
| |
| var frontendFS embed.FS |
|
|
| |
| type PublicSettingsProvider interface { |
| GetPublicSettingsForInjection(ctx context.Context) (any, error) |
| } |
|
|
| |
| type FrontendServer struct { |
| distFS fs.FS |
| fileServer http.Handler |
| baseHTML []byte |
| cache *HTMLCache |
| settings PublicSettingsProvider |
| } |
|
|
| |
| func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) { |
| distFS, err := fs.Sub(frontendFS, "dist") |
| if err != nil { |
| return nil, err |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| func (s *FrontendServer) InvalidateCache() { |
| if s != nil && s.cache != nil { |
| s.cache.Invalidate() |
| } |
| } |
|
|
| |
| func (s *FrontendServer) Middleware() gin.HandlerFunc { |
| 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 cleanPath == "index.html" || !s.fileExists(cleanPath) { |
| s.serveIndexHTML(c) |
| return |
| } |
|
|
| |
| 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) { |
| |
| nonce := middleware.GetNonceFromContext(c) |
|
|
| |
| cached := s.cache.Get() |
| if cached != nil { |
| |
| if match := c.GetHeader("If-None-Match"); match == cached.ETag { |
| c.Status(http.StatusNotModified) |
| c.Abort() |
| return |
| } |
|
|
| |
| content := replaceNoncePlaceholder(cached.Content, nonce) |
|
|
| c.Header("ETag", cached.ETag) |
| c.Header("Cache-Control", "no-cache") |
| c.Data(http.StatusOK, "text/html; charset=utf-8", content) |
| c.Abort() |
| return |
| } |
|
|
| |
| ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) |
| defer cancel() |
|
|
| settings, err := s.settings.GetPublicSettingsForInjection(ctx) |
| if err != nil { |
| |
| c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) |
| c.Abort() |
| return |
| } |
|
|
| settingsJSON, err := json.Marshal(settings) |
| if err != nil { |
| |
| c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) |
| c.Abort() |
| return |
| } |
|
|
| rendered := s.injectSettings(settingsJSON) |
| s.cache.Set(rendered, settingsJSON) |
|
|
| |
| 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 { |
| |
| |
| script := []byte(`<script nonce="` + NonceHTMLPlaceholder + `">window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`) |
|
|
| |
| headClose := []byte("</head>") |
| result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1) |
|
|
| |
| result = injectSiteTitle(result, settingsJSON) |
|
|
| return result |
| } |
|
|
| |
| |
| 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 |
| } |
|
|
| |
| titleStart := bytes.Index(html, []byte("<title>")) |
| titleEnd := bytes.Index(html, []byte("</title>")) |
| if titleStart == -1 || titleEnd == -1 || titleEnd <= titleStart { |
| return html |
| } |
|
|
| newTitle := []byte("<title>" + 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() |
| } |
|
|
| |
| func replaceNoncePlaceholder(html []byte, nonce string) []byte { |
| return bytes.ReplaceAll(html, []byte(NonceHTMLPlaceholder), []byte(nonce)) |
| } |
|
|
| |
| |
| 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 shouldBypassEmbeddedFrontend(path) { |
| 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 shouldBypassEmbeddedFrontend(path string) bool { |
| trimmed := strings.TrimSpace(path) |
| return strings.HasPrefix(trimmed, "/api/") || |
| strings.HasPrefix(trimmed, "/v1/") || |
| strings.HasPrefix(trimmed, "/v1beta/") || |
| strings.HasPrefix(trimmed, "/sora/") || |
| strings.HasPrefix(trimmed, "/antigravity/") || |
| strings.HasPrefix(trimmed, "/setup/") || |
| trimmed == "/health" || |
| trimmed == "/responses" || |
| strings.HasPrefix(trimmed, "/responses/") |
| } |
|
|
| 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 |
| } |
|
|