Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
//go:build !embed
|
||||
|
||||
// Package web provides embedded web assets for the application.
|
||||
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) (any, 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.")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func HasEmbeddedFrontend() bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//go:build embed
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func TestInjectSiteTitle(t *testing.T) {
|
||||
t.Run("replaces_title_with_site_name", func(t *testing.T) {
|
||||
html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{"site_name":"MyCustomSite"}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.Contains(t, string(result), "<title>MyCustomSite - AI API Gateway</title>")
|
||||
assert.NotContains(t, string(result), "Sub2API")
|
||||
})
|
||||
|
||||
t.Run("returns_unchanged_when_site_name_empty", func(t *testing.T) {
|
||||
html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{"site_name":""}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("returns_unchanged_when_site_name_missing", func(t *testing.T) {
|
||||
html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{"other_field":"value"}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("returns_unchanged_when_invalid_json", func(t *testing.T) {
|
||||
html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{invalid json}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("returns_unchanged_when_no_title_tag", func(t *testing.T) {
|
||||
html := []byte(`<html><head></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{"site_name":"MyCustomSite"}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("returns_unchanged_when_title_has_attributes", func(t *testing.T) {
|
||||
// The function looks for "<title>" literally, so attributes are not supported
|
||||
// This is acceptable since index.html uses plain <title> without attributes
|
||||
html := []byte(`<html><head><title lang="en">Sub2API</title></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{"site_name":"NewSite"}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
// Should return unchanged since <title> with attributes is not matched
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("escapes_html_in_site_name", func(t *testing.T) {
|
||||
html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{"site_name":"</title><script>alert(1)</script><title>"}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.NotContains(t, string(result), "<script>")
|
||||
assert.Contains(t, string(result), "</title><script>alert(1)</script><title>")
|
||||
})
|
||||
|
||||
t.Run("escapes_ampersand_in_site_name", func(t *testing.T) {
|
||||
html := []byte(`<html><head><title>Sub2API</title></head><body></body></html>`)
|
||||
settingsJSON := []byte(`{"site_name":"A&B"}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.Contains(t, string(result), "<title>A&B - AI API Gateway</title>")
|
||||
})
|
||||
|
||||
t.Run("preserves_rest_of_html", func(t *testing.T) {
|
||||
html := []byte(`<html><head><meta charset="UTF-8"><title>Sub2API</title><script src="app.js"></script></head><body><div id="app"></div></body></html>`)
|
||||
settingsJSON := []byte(`{"site_name":"TestSite"}`)
|
||||
|
||||
result := injectSiteTitle(html, settingsJSON)
|
||||
|
||||
assert.Contains(t, string(result), `<meta charset="UTF-8">`)
|
||||
assert.Contains(t, string(result), `<script src="app.js"></script>`)
|
||||
assert.Contains(t, string(result), `<div id="app"></div>`)
|
||||
assert.Contains(t, string(result), "<title>TestSite - AI API Gateway</title>")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInjectSiteFavicon(t *testing.T) {
|
||||
t.Run("replaces_favicon_with_site_logo", func(t *testing.T) {
|
||||
html := []byte(`<html><head><link rel="icon" type="image/png" href="/logo.png" /></head></html>`)
|
||||
settingsJSON := []byte(`{"site_logo":"https://example.com/custom-logo.png"}`)
|
||||
|
||||
result := injectSiteFavicon(html, settingsJSON)
|
||||
|
||||
assert.Contains(t, string(result), `<link rel="icon" href="https://example.com/custom-logo.png" />`)
|
||||
assert.NotContains(t, string(result), `/logo.png`)
|
||||
})
|
||||
|
||||
t.Run("supports_relative_and_data_image_urls", func(t *testing.T) {
|
||||
html := []byte(`<link rel="icon" href="/logo.png" />`)
|
||||
|
||||
assert.Contains(t, string(injectSiteFavicon(html, []byte(`{"site_logo":"/uploads/logo.svg"}`))), `/uploads/logo.svg`)
|
||||
assert.Contains(t, string(injectSiteFavicon(html, []byte(`{"site_logo":"data:image/png;base64,abc"}`))), `data:image/png;base64,abc`)
|
||||
})
|
||||
|
||||
t.Run("rejects_unsafe_logo_urls", func(t *testing.T) {
|
||||
html := []byte(`<link rel="icon" href="/logo.png" />`)
|
||||
|
||||
result := injectSiteFavicon(html, []byte(`{"site_logo":"javascript:alert(1)"}`))
|
||||
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("escapes_logo_url_for_html", func(t *testing.T) {
|
||||
html := []byte(`<link rel="icon" href="/logo.png" />`)
|
||||
|
||||
result := injectSiteFavicon(html, []byte(`{"site_logo":"https://example.com/logo.png?a=1&b=2"}`))
|
||||
|
||||
assert.Contains(t, string(result), `a=1&b=2`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplaceNoncePlaceholder(t *testing.T) {
|
||||
t.Run("replaces_single_placeholder", func(t *testing.T) {
|
||||
html := []byte(`<script nonce="__CSP_NONCE_VALUE__">console.log('test');</script>`)
|
||||
nonce := "abc123xyz"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
expected := `<script nonce="abc123xyz">console.log('test');</script>`
|
||||
assert.Equal(t, expected, string(result))
|
||||
})
|
||||
|
||||
t.Run("replaces_multiple_placeholders", func(t *testing.T) {
|
||||
html := []byte(`<script nonce="__CSP_NONCE_VALUE__">a</script><script nonce="__CSP_NONCE_VALUE__">b</script>`)
|
||||
nonce := "nonce123"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Equal(t, 2, strings.Count(string(result), `nonce="nonce123"`))
|
||||
assert.NotContains(t, string(result), NonceHTMLPlaceholder)
|
||||
})
|
||||
|
||||
t.Run("handles_empty_nonce", func(t *testing.T) {
|
||||
html := []byte(`<script nonce="__CSP_NONCE_VALUE__">test</script>`)
|
||||
nonce := ""
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Equal(t, `<script nonce="">test</script>`, string(result))
|
||||
})
|
||||
|
||||
t.Run("no_placeholder_returns_unchanged", func(t *testing.T) {
|
||||
html := []byte(`<script>console.log('test');</script>`)
|
||||
nonce := "abc123"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("handles_empty_html", func(t *testing.T) {
|
||||
html := []byte(``)
|
||||
nonce := "abc123"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNonceHTMLPlaceholder(t *testing.T) {
|
||||
t.Run("constant_value", func(t *testing.T) {
|
||||
assert.Equal(t, "__CSP_NONCE_VALUE__", NonceHTMLPlaceholder)
|
||||
})
|
||||
}
|
||||
|
||||
// mockSettingsProvider implements PublicSettingsProvider for testing
|
||||
type mockSettingsProvider struct {
|
||||
settings any
|
||||
err error
|
||||
called int
|
||||
}
|
||||
|
||||
func (m *mockSettingsProvider) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
||||
m.called++
|
||||
return m.settings, m.err
|
||||
}
|
||||
|
||||
func TestFrontendServer_InjectSettings(t *testing.T) {
|
||||
t.Run("injects_settings_with_nonce_placeholder", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"key": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := []byte(`{"test":"data"}`)
|
||||
result := server.injectSettings(settingsJSON)
|
||||
|
||||
// Should contain the script with nonce placeholder
|
||||
assert.Contains(t, string(result), `<script nonce="__CSP_NONCE_VALUE__">`)
|
||||
assert.Contains(t, string(result), `window.__APP_CONFIG__={"test":"data"};`)
|
||||
assert.Contains(t, string(result), `</script></head>`)
|
||||
})
|
||||
|
||||
t.Run("injects_before_head_close", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"key": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := []byte(`{}`)
|
||||
result := server.injectSettings(settingsJSON)
|
||||
|
||||
// Script should be injected before </head>
|
||||
headCloseIndex := bytes.Index(result, []byte("</head>"))
|
||||
scriptIndex := bytes.Index(result, []byte(`<script nonce="`))
|
||||
|
||||
assert.True(t, scriptIndex < headCloseIndex, "script should be before </head>")
|
||||
})
|
||||
|
||||
t.Run("handles_complex_settings", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]any{
|
||||
"nested": map[string]any{
|
||||
"array": []int{1, 2, 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := []byte(`{"nested":{"array":[1,2,3]},"special":"<>&"}`)
|
||||
result := server.injectSettings(settingsJSON)
|
||||
|
||||
assert.Contains(t, string(result), `window.__APP_CONFIG__={"nested":{"array":[1,2,3]},"special":"<>&"};`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendServer_ServeIndexHTML(t *testing.T) {
|
||||
t.Run("serves_html_with_nonce", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a gin context with nonce
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
// Set nonce in context (simulating SecurityHeaders middleware)
|
||||
testNonce := "test-nonce-12345"
|
||||
c.Set(middleware.CSPNonceKey, testNonce)
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
|
||||
body := w.Body.String()
|
||||
// Nonce placeholder should be replaced
|
||||
assert.NotContains(t, body, NonceHTMLPlaceholder)
|
||||
assert.Contains(t, body, `nonce="`+testNonce+`"`)
|
||||
})
|
||||
|
||||
t.Run("caches_html_content", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First request
|
||||
w1 := httptest.NewRecorder()
|
||||
c1, _ := gin.CreateTestContext(w1)
|
||||
c1.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c1.Set(middleware.CSPNonceKey, "nonce1")
|
||||
|
||||
server.serveIndexHTML(c1)
|
||||
assert.Equal(t, 1, provider.called)
|
||||
|
||||
// Second request - should use cache
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c2.Set(middleware.CSPNonceKey, "nonce2")
|
||||
|
||||
server.serveIndexHTML(c2)
|
||||
// Settings provider should not be called again
|
||||
assert.Equal(t, 1, provider.called)
|
||||
|
||||
// But nonce should be different
|
||||
assert.Contains(t, w2.Body.String(), `nonce="nonce2"`)
|
||||
})
|
||||
|
||||
t.Run("sets_etag_header", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "nonce123")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
etag := w.Header().Get("ETag")
|
||||
assert.NotEmpty(t, etag)
|
||||
assert.True(t, strings.HasPrefix(etag, `"`))
|
||||
assert.True(t, strings.HasSuffix(etag, `"`))
|
||||
})
|
||||
|
||||
t.Run("returns_304_for_matching_etag", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use a real router for proper 304 handling
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CSPNonceKey, "test-nonce")
|
||||
c.Next()
|
||||
})
|
||||
router.Use(server.Middleware())
|
||||
|
||||
// First request to populate cache and get ETag
|
||||
w1 := httptest.NewRecorder()
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
router.ServeHTTP(w1, req1)
|
||||
etag := w1.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
|
||||
// Second request with If-None-Match
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
assert.Equal(t, http.StatusNotModified, w2.Code)
|
||||
assert.Empty(t, w2.Body.String())
|
||||
})
|
||||
|
||||
t.Run("sets_cache_control_header", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "nonce123")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
|
||||
})
|
||||
|
||||
t.Run("fallback_on_settings_error", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
err: context.DeadlineExceeded,
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Invalidate cache to force settings fetch
|
||||
server.InvalidateCache()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "nonce123")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
// Should still return 200 with base HTML
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendServer_InvalidateCache(t *testing.T) {
|
||||
t.Run("invalidates_cache", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First request to populate cache
|
||||
w1 := httptest.NewRecorder()
|
||||
c1, _ := gin.CreateTestContext(w1)
|
||||
c1.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c1.Set(middleware.CSPNonceKey, "nonce1")
|
||||
|
||||
server.serveIndexHTML(c1)
|
||||
assert.Equal(t, 1, provider.called)
|
||||
|
||||
// Invalidate cache
|
||||
server.InvalidateCache()
|
||||
|
||||
// Update settings
|
||||
provider.settings = map[string]string{"test": "new_value"}
|
||||
|
||||
// Second request should fetch new settings
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c2.Set(middleware.CSPNonceKey, "nonce2")
|
||||
|
||||
server.serveIndexHTML(c2)
|
||||
assert.Equal(t, 2, provider.called)
|
||||
})
|
||||
|
||||
t.Run("handles_nil_server", func(t *testing.T) {
|
||||
var server *FrontendServer
|
||||
// Should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
server.InvalidateCache()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("handles_nil_cache", func(t *testing.T) {
|
||||
server := &FrontendServer{}
|
||||
// Should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
server.InvalidateCache()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestOverrideFilesNeverReceiveImmutableCacheHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
overrideDir := t.TempDir()
|
||||
cleanPath := "assets/index-AbCd1234.js"
|
||||
filePath := filepath.Join(overrideDir, cleanPath)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("override"), 0o644))
|
||||
|
||||
t.Run("frontend_server_override", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/"+cleanPath, nil)
|
||||
|
||||
server := &FrontendServer{overrideDir: overrideDir}
|
||||
assert.True(t, server.tryServeOverride(c, cleanPath))
|
||||
assert.Empty(t, w.Header().Get("Cache-Control"))
|
||||
})
|
||||
|
||||
t.Run("legacy_override", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/"+cleanPath, nil)
|
||||
|
||||
assert.True(t, tryServeOverrideFile(c, overrideDir, cleanPath))
|
||||
assert.Empty(t, w.Header().Get("Cache-Control"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendServer_Middleware(t *testing.T) {
|
||||
t.Run("skips_api_routes", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiPaths := []string{
|
||||
"/api/v1/users",
|
||||
"/models",
|
||||
"/v1/models",
|
||||
"/v1beta/chat",
|
||||
"/backend-api/codex/responses",
|
||||
"/backend-api/codex/responses/compact",
|
||||
"/antigravity/test",
|
||||
"/setup/init",
|
||||
"/health",
|
||||
"/responses",
|
||||
"/responses/compact",
|
||||
}
|
||||
|
||||
for _, path := range apiPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(server.Middleware())
|
||||
nextCalled := false
|
||||
router.GET(path, func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.True(t, nextCalled, "next handler should be called for API route")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips_responses_compact_post_routes", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(server.Middleware())
|
||||
nextCalled := false
|
||||
router.POST("/responses/compact", func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.String(http.StatusOK, `{"ok":true}`)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/responses/compact", strings.NewReader(`{"model":"gpt-5"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.True(t, nextCalled, "next handler should be called for compact API route")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.JSONEq(t, `{"ok":true}`, w.Body.String())
|
||||
})
|
||||
|
||||
t.Run("skips_alpha_search_post_route", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(server.Middleware())
|
||||
nextCalled := false
|
||||
router.POST("/alpha/search", func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/alpha/search", strings.NewReader(`{"model":"gpt-5.6-sol"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.True(t, nextCalled, "next handler should be called for alpha search API route")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.JSONEq(t, `{"ok":true}`, w.Body.String())
|
||||
})
|
||||
|
||||
t.Run("serves_index_for_spa_routes", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CSPNonceKey, "test-nonce")
|
||||
c.Next()
|
||||
})
|
||||
router.Use(server.Middleware())
|
||||
|
||||
spaPaths := []string{
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/users/123",
|
||||
"/settings/profile",
|
||||
}
|
||||
|
||||
for _, path := range spaPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("serves_static_files", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(server.Middleware())
|
||||
|
||||
// Request for existing static file
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/logo.png", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "image/png")
|
||||
assert.Empty(t, w.Header().Get("Cache-Control"))
|
||||
|
||||
entries, err := fs.ReadDir(server.distFS, "assets")
|
||||
require.NoError(t, err)
|
||||
fingerprintedPath := ""
|
||||
for _, entry := range entries {
|
||||
candidate := "assets/" + entry.Name()
|
||||
if !entry.IsDir() && isFingerprintedEmbeddedAssetPath(candidate) {
|
||||
fingerprintedPath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, fingerprintedPath)
|
||||
|
||||
assetWriter := httptest.NewRecorder()
|
||||
assetRequest := httptest.NewRequest(http.MethodGet, "/"+fingerprintedPath, nil)
|
||||
router.ServeHTTP(assetWriter, assetRequest)
|
||||
|
||||
assert.Equal(t, http.StatusOK, assetWriter.Code)
|
||||
assert.Equal(t, staticAssetsCacheControl, assetWriter.Header().Get("Cache-Control"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEmbeddedFrontendBypassesBareVideoAPIRoutes(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/videos/generations",
|
||||
"/videos/edits",
|
||||
"/videos/extensions",
|
||||
"/videos/request-123",
|
||||
} {
|
||||
require.True(t, shouldBypassEmbeddedFrontend(path), "path=%s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFrontendServer(t *testing.T) {
|
||||
t.Run("creates_server_successfully", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, server)
|
||||
assert.NotNil(t, server.distFS)
|
||||
assert.NotNil(t, server.fileServer)
|
||||
assert.NotNil(t, server.baseHTML)
|
||||
assert.NotNil(t, server.cache)
|
||||
assert.Equal(t, provider, server.settings)
|
||||
})
|
||||
|
||||
t.Run("reads_base_html", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, server.baseHTML)
|
||||
assert.Contains(t, string(server.baseHTML), "<!doctype html>")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasEmbeddedFrontend(t *testing.T) {
|
||||
t.Run("returns_true_when_frontend_embedded", func(t *testing.T) {
|
||||
result := HasEmbeddedFrontend()
|
||||
assert.True(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// Tests for legacy ServeEmbeddedFrontend function
|
||||
func TestServeEmbeddedFrontend(t *testing.T) {
|
||||
t.Run("serves_static_files", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/logo.png", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "image/png")
|
||||
})
|
||||
|
||||
t.Run("serves_index_html_for_root", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
assert.Contains(t, w.Body.String(), "<!doctype html>")
|
||||
})
|
||||
|
||||
t.Run("serves_index_html_for_spa_routes", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
|
||||
spaPaths := []string{"/dashboard", "/users/123", "/settings"}
|
||||
|
||||
for _, path := range spaPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips_api_routes", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
apiPaths := []string{
|
||||
"/api/users",
|
||||
"/models",
|
||||
"/v1/models",
|
||||
"/v1beta/chat",
|
||||
"/backend-api/codex/responses",
|
||||
"/backend-api/codex/responses/compact",
|
||||
"/antigravity/test",
|
||||
"/setup/init",
|
||||
"/health",
|
||||
"/responses",
|
||||
"/responses/compact",
|
||||
}
|
||||
|
||||
for _, path := range apiPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
nextCalled := false
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
router.GET(path, func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.True(t, nextCalled, "next handler should be called for API route")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Tests for HTMLCache
|
||||
func TestHTMLCache(t *testing.T) {
|
||||
t.Run("new_cache_returns_nil", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
assert.Nil(t, cache.Get())
|
||||
})
|
||||
|
||||
t.Run("set_and_get", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
html := []byte("<html><body>test</body></html>")
|
||||
settings := []byte(`{"key":"value"}`)
|
||||
cache.Set(html, settings)
|
||||
|
||||
result := cache.Get()
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, html, result.Content)
|
||||
assert.NotEmpty(t, result.ETag)
|
||||
})
|
||||
|
||||
t.Run("invalidate_clears_cache", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
html := []byte("<html><body>test</body></html>")
|
||||
settings := []byte(`{"key":"value"}`)
|
||||
cache.Set(html, settings)
|
||||
|
||||
require.NotNil(t, cache.Get())
|
||||
|
||||
cache.Invalidate()
|
||||
|
||||
assert.Nil(t, cache.Get())
|
||||
})
|
||||
|
||||
t.Run("etag_changes_with_settings", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
html := []byte("<html><body>test</body></html>")
|
||||
|
||||
cache.Set(html, []byte(`{"v":1}`))
|
||||
etag1 := cache.Get().ETag
|
||||
|
||||
cache.Invalidate()
|
||||
cache.Set(html, []byte(`{"v":2}`))
|
||||
etag2 := cache.Get().ETag
|
||||
|
||||
assert.NotEqual(t, etag1, etag2)
|
||||
})
|
||||
|
||||
t.Run("etag_format", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
cache.Set([]byte("<html></html>"), []byte(`{}`))
|
||||
result := cache.Get()
|
||||
|
||||
// ETag should be quoted
|
||||
assert.True(t, strings.HasPrefix(result.ETag, `"`))
|
||||
assert.True(t, strings.HasSuffix(result.ETag, `"`))
|
||||
// Should contain dash separator
|
||||
assert.Contains(t, result.ETag[1:len(result.ETag)-1], "-")
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkReplaceNoncePlaceholder(b *testing.B) {
|
||||
html := []byte(`<!DOCTYPE html><html><head><script nonce="__CSP_NONCE_VALUE__">window.__APP_CONFIG__={"test":"data"};</script></head><body></body></html>`)
|
||||
nonce := "abcdefghijklmnop123456=="
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
replaceNoncePlaceholder(html, nonce)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFrontendServerServeIndexHTML(b *testing.B) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, _ := NewFrontendServer(provider)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "test-nonce")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
}
|
||||
}
|
||||
@@ -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]) + `"`
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build embed || unit
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Vite emits content-hashed filenames under assets/, so the backend can apply
|
||||
// immutable caching without relying on a reverse proxy to classify paths.
|
||||
const staticAssetsCacheControl = "public, max-age=31536000, immutable"
|
||||
|
||||
// isFingerprintedEmbeddedAssetPath reports whether a cleaned URL path refers to
|
||||
// a Vite asset whose filename contains the default eight-character build hash.
|
||||
func isFingerprintedEmbeddedAssetPath(cleanPath string) bool {
|
||||
cleanPath = strings.TrimPrefix(cleanPath, "/")
|
||||
if !strings.HasPrefix(cleanPath, "assets/") {
|
||||
return false
|
||||
}
|
||||
|
||||
filename := path.Base(cleanPath)
|
||||
extension := path.Ext(filename)
|
||||
stem := strings.TrimSuffix(filename, extension)
|
||||
const fingerprintLength = 8
|
||||
delimiterIndex := len(stem) - fingerprintLength - 1
|
||||
if extension == "" || delimiterIndex < 1 || stem[delimiterIndex] != '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
// Vite hashes use URL-safe characters and are stable for immutable caching.
|
||||
fingerprint := stem[delimiterIndex+1:]
|
||||
for _, char := range fingerprint {
|
||||
if (char >= 'a' && char <= 'z') ||
|
||||
(char >= 'A' && char <= 'Z') ||
|
||||
(char >= '0' && char <= '9') ||
|
||||
char == '_' || char == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyStaticAssetCacheHeaders sets Cache-Control for long-cacheable static paths.
|
||||
// index.html / SPA routes must keep no-cache and are not handled here.
|
||||
func applyStaticAssetCacheHeaders(header http.Header, cleanPath string) {
|
||||
if header == nil || !isFingerprintedEmbeddedAssetPath(cleanPath) {
|
||||
return
|
||||
}
|
||||
header.Set("Cache-Control", staticAssetsCacheControl)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build unit
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsFingerprintedEmbeddedAssetPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{name: "fingerprinted_js", path: "assets/index-AbCd1234.js", want: true},
|
||||
{name: "fingerprinted_css", path: "assets/app-a1B2c3D4.css", want: true},
|
||||
{name: "fingerprinted_url_safe_hash", path: "assets/app-aB1-2_Cd.css", want: true},
|
||||
{name: "nested_fingerprinted_asset", path: "assets/vendor/chunk-AbCd1234.js", want: true},
|
||||
{name: "leading_slash_fingerprinted_asset", path: "/assets/index-AbCd1234.js", want: true},
|
||||
{name: "unhashed_asset", path: "assets/index.js", want: false},
|
||||
{name: "short_suffix", path: "assets/index-abc123.js", want: false},
|
||||
{name: "logo", path: "logo.png", want: false},
|
||||
{name: "favicon", path: "favicon.ico", want: false},
|
||||
{name: "fingerprint_outside_assets", path: "downloads/index-AbCd1234.js", want: false},
|
||||
{name: "index_html", path: "index.html", want: false},
|
||||
{name: "spa_route", path: "dashboard", want: false},
|
||||
{name: "assets_prefix_only", path: "assets", want: false},
|
||||
{name: "similar_name", path: "assets-backup/x.js", want: false},
|
||||
{name: "empty", path: "", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, isFingerprintedEmbeddedAssetPath(tc.path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStaticAssetCacheHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("sets_immutable_cache_for_fingerprinted_asset", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
header := make(http.Header)
|
||||
applyStaticAssetCacheHeaders(header, "assets/index-AbCd1234.js")
|
||||
assert.Equal(t, staticAssetsCacheControl, header.Get("Cache-Control"))
|
||||
})
|
||||
|
||||
for _, path := range []string{"assets/index.js", "logo.png", "favicon.ico", "index.html"} {
|
||||
path := path
|
||||
t.Run("skips_"+path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
header := make(http.Header)
|
||||
applyStaticAssetCacheHeaders(header, path)
|
||||
assert.Empty(t, header.Get("Cache-Control"))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nil_header_is_noop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.NotPanics(t, func() {
|
||||
applyStaticAssetCacheHeaders(nil, "assets/index-AbCd1234.js")
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user