embed_on.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. //go:build embed
  2. package web
  3. import (
  4. "bytes"
  5. "context"
  6. "embed"
  7. "encoding/json"
  8. htmlpkg "html"
  9. "io"
  10. "io/fs"
  11. "net/http"
  12. "net/url"
  13. "os"
  14. "path/filepath"
  15. "strings"
  16. "time"
  17. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  18. "github.com/gin-gonic/gin"
  19. )
  20. const (
  21. // NonceHTMLPlaceholder is the placeholder for nonce in HTML script tags
  22. NonceHTMLPlaceholder = "__CSP_NONCE_VALUE__"
  23. )
  24. //go:embed all:dist
  25. var frontendFS embed.FS
  26. // PublicSettingsProvider is an interface to fetch public settings
  27. type PublicSettingsProvider interface {
  28. GetPublicSettingsForInjection(ctx context.Context) (any, error)
  29. }
  30. // FrontendServer serves the embedded frontend with settings injection
  31. type FrontendServer struct {
  32. distFS fs.FS
  33. fileServer http.Handler
  34. baseHTML []byte
  35. cache *HTMLCache
  36. settings PublicSettingsProvider
  37. overrideDir string // local file override directory
  38. }
  39. // NewFrontendServer creates a new frontend server with settings injection
  40. func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) {
  41. distFS, err := fs.Sub(frontendFS, "dist")
  42. if err != nil {
  43. return nil, err
  44. }
  45. // Read base HTML once
  46. file, err := distFS.Open("index.html")
  47. if err != nil {
  48. return nil, err
  49. }
  50. defer func() { _ = file.Close() }()
  51. baseHTML, err := io.ReadAll(file)
  52. if err != nil {
  53. return nil, err
  54. }
  55. cache := NewHTMLCache()
  56. cache.SetBaseHTML(baseHTML)
  57. return &FrontendServer{
  58. distFS: distFS,
  59. fileServer: http.FileServer(http.FS(distFS)),
  60. baseHTML: baseHTML,
  61. cache: cache,
  62. settings: settingsProvider,
  63. overrideDir: filepath.Join("data", "public"),
  64. }, nil
  65. }
  66. // InvalidateCache invalidates the HTML cache (call when settings change)
  67. func (s *FrontendServer) InvalidateCache() {
  68. if s != nil && s.cache != nil {
  69. s.cache.Invalidate()
  70. }
  71. }
  72. // Middleware returns the Gin middleware handler
  73. func (s *FrontendServer) Middleware() gin.HandlerFunc {
  74. return func(c *gin.Context) {
  75. path := c.Request.URL.Path
  76. // Skip API routes
  77. if shouldBypassEmbeddedFrontend(path) {
  78. c.Next()
  79. return
  80. }
  81. cleanPath := strings.TrimPrefix(path, "/")
  82. if cleanPath == "" {
  83. cleanPath = "index.html"
  84. }
  85. // For index.html or SPA routes, serve with injected settings
  86. if cleanPath == "index.html" || !s.fileExists(cleanPath) {
  87. s.serveIndexHTML(c)
  88. return
  89. }
  90. // Try local override first
  91. if s.tryServeOverride(c, cleanPath) {
  92. return
  93. }
  94. // Serve static files normally (hashed assets get long-lived cache headers)
  95. applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath)
  96. s.fileServer.ServeHTTP(c.Writer, c.Request)
  97. c.Abort()
  98. }
  99. }
  100. func (s *FrontendServer) fileExists(path string) bool {
  101. file, err := s.distFS.Open(path)
  102. if err != nil {
  103. return false
  104. }
  105. _ = file.Close()
  106. return true
  107. }
  108. // tryServeOverride checks if a local override file exists and serves it.
  109. // Files in overrideDir take precedence over embedded files.
  110. func (s *FrontendServer) tryServeOverride(c *gin.Context, cleanPath string) bool {
  111. if s.overrideDir == "" {
  112. return false
  113. }
  114. filePath := filepath.Join(s.overrideDir, filepath.Clean("/"+cleanPath))
  115. info, err := os.Stat(filePath)
  116. if err != nil || info.IsDir() {
  117. return false
  118. }
  119. c.File(filePath)
  120. c.Abort()
  121. return true
  122. }
  123. func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
  124. // Get nonce from context (generated by SecurityHeaders middleware)
  125. nonce := middleware.GetNonceFromContext(c)
  126. // Check cache first
  127. cached := s.cache.Get()
  128. if cached != nil {
  129. // Check If-None-Match for 304 response
  130. if match := c.GetHeader("If-None-Match"); match == cached.ETag {
  131. c.Status(http.StatusNotModified)
  132. c.Abort()
  133. return
  134. }
  135. // Replace nonce placeholder with actual nonce before serving
  136. content := replaceNoncePlaceholder(cached.Content, nonce)
  137. c.Header("ETag", cached.ETag)
  138. c.Header("Cache-Control", "no-cache") // Must revalidate
  139. c.Data(http.StatusOK, "text/html; charset=utf-8", content)
  140. c.Abort()
  141. return
  142. }
  143. // Cache miss - fetch settings and render
  144. ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
  145. defer cancel()
  146. settings, err := s.settings.GetPublicSettingsForInjection(ctx)
  147. if err != nil {
  148. // Fallback: serve without injection
  149. c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML)
  150. c.Abort()
  151. return
  152. }
  153. settingsJSON, err := json.Marshal(settings)
  154. if err != nil {
  155. // Fallback: serve without injection
  156. c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML)
  157. c.Abort()
  158. return
  159. }
  160. rendered := s.injectSettings(settingsJSON)
  161. s.cache.Set(rendered, settingsJSON)
  162. // Replace nonce placeholder with actual nonce before serving
  163. content := replaceNoncePlaceholder(rendered, nonce)
  164. cached = s.cache.Get()
  165. if cached != nil {
  166. c.Header("ETag", cached.ETag)
  167. }
  168. c.Header("Cache-Control", "no-cache")
  169. c.Data(http.StatusOK, "text/html; charset=utf-8", content)
  170. c.Abort()
  171. }
  172. func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte {
  173. // Create the script tag to inject with nonce placeholder
  174. // The placeholder will be replaced with actual nonce at request time
  175. script := []byte(`<script nonce="` + NonceHTMLPlaceholder + `">window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
  176. // Inject before </head>
  177. headClose := []byte("</head>")
  178. result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
  179. // Apply custom branding before the browser paints the static defaults.
  180. result = injectSiteTitle(result, settingsJSON)
  181. result = injectSiteFavicon(result, settingsJSON)
  182. return result
  183. }
  184. // injectSiteFavicon replaces the static favicon with a configured, browser-safe image URL.
  185. func injectSiteFavicon(html, settingsJSON []byte) []byte {
  186. var cfg struct {
  187. SiteLogo string `json:"site_logo"`
  188. }
  189. if err := json.Unmarshal(settingsJSON, &cfg); err != nil {
  190. return html
  191. }
  192. logoURL := safeImageURL(cfg.SiteLogo)
  193. if logoURL == "" {
  194. return html
  195. }
  196. linkStart := bytes.Index(html, []byte(`<link rel="icon"`))
  197. if linkStart == -1 {
  198. return html
  199. }
  200. linkEndOffset := bytes.IndexByte(html[linkStart:], '>')
  201. if linkEndOffset == -1 {
  202. return html
  203. }
  204. linkEnd := linkStart + linkEndOffset + 1
  205. replacement := []byte(`<link rel="icon" href="` + htmlpkg.EscapeString(logoURL) + `" />`)
  206. var buf bytes.Buffer
  207. buf.Write(html[:linkStart])
  208. buf.Write(replacement)
  209. buf.Write(html[linkEnd:])
  210. return buf.Bytes()
  211. }
  212. func safeImageURL(value string) string {
  213. trimmed := strings.TrimSpace(value)
  214. if trimmed == "" {
  215. return ""
  216. }
  217. if strings.HasPrefix(trimmed, "/") && !strings.HasPrefix(trimmed, "//") {
  218. return trimmed
  219. }
  220. if strings.HasPrefix(strings.ToLower(trimmed), "data:image/") {
  221. return trimmed
  222. }
  223. parsed, err := url.Parse(trimmed)
  224. if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
  225. return ""
  226. }
  227. return trimmed
  228. }
  229. // injectSiteTitle replaces the static <title> in HTML with the configured site name.
  230. // This ensures the browser tab shows the correct title before JS executes.
  231. func injectSiteTitle(html, settingsJSON []byte) []byte {
  232. var cfg struct {
  233. SiteName string `json:"site_name"`
  234. }
  235. if err := json.Unmarshal(settingsJSON, &cfg); err != nil || cfg.SiteName == "" {
  236. return html
  237. }
  238. // Find and replace the existing <title>...</title>
  239. titleStart := bytes.Index(html, []byte("<title>"))
  240. titleEnd := bytes.Index(html, []byte("</title>"))
  241. if titleStart == -1 || titleEnd == -1 || titleEnd <= titleStart {
  242. return html
  243. }
  244. newTitle := []byte("<title>" + htmlpkg.EscapeString(cfg.SiteName) + " - AI API Gateway</title>")
  245. var buf bytes.Buffer
  246. buf.Write(html[:titleStart])
  247. buf.Write(newTitle)
  248. buf.Write(html[titleEnd+len("</title>"):])
  249. return buf.Bytes()
  250. }
  251. // replaceNoncePlaceholder replaces the nonce placeholder with actual nonce value
  252. func replaceNoncePlaceholder(html []byte, nonce string) []byte {
  253. return bytes.ReplaceAll(html, []byte(NonceHTMLPlaceholder), []byte(nonce))
  254. }
  255. // ServeEmbeddedFrontend returns a middleware for serving embedded frontend
  256. // This is the legacy function for backward compatibility when no settings provider is available
  257. func ServeEmbeddedFrontend() gin.HandlerFunc {
  258. distFS, err := fs.Sub(frontendFS, "dist")
  259. if err != nil {
  260. panic("failed to get dist subdirectory: " + err.Error())
  261. }
  262. fileServer := http.FileServer(http.FS(distFS))
  263. overrideDir := filepath.Join("data", "public")
  264. return func(c *gin.Context) {
  265. path := c.Request.URL.Path
  266. if shouldBypassEmbeddedFrontend(path) {
  267. c.Next()
  268. return
  269. }
  270. cleanPath := strings.TrimPrefix(path, "/")
  271. if cleanPath == "" {
  272. cleanPath = "index.html"
  273. }
  274. if file, err := distFS.Open(cleanPath); err == nil {
  275. _ = file.Close()
  276. // Try local override first
  277. if tryServeOverrideFile(c, overrideDir, cleanPath) {
  278. return
  279. }
  280. applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath)
  281. fileServer.ServeHTTP(c.Writer, c.Request)
  282. c.Abort()
  283. return
  284. }
  285. serveIndexHTML(c, distFS)
  286. }
  287. }
  288. // tryServeOverrideFile is a standalone version of tryServeOverride for legacy usage.
  289. func tryServeOverrideFile(c *gin.Context, overrideDir, cleanPath string) bool {
  290. if overrideDir == "" {
  291. return false
  292. }
  293. filePath := filepath.Join(overrideDir, filepath.Clean("/"+cleanPath))
  294. info, err := os.Stat(filePath)
  295. if err != nil || info.IsDir() {
  296. return false
  297. }
  298. c.File(filePath)
  299. c.Abort()
  300. return true
  301. }
  302. func shouldBypassEmbeddedFrontend(path string) bool {
  303. trimmed := strings.TrimSpace(path)
  304. return strings.HasPrefix(trimmed, "/api/") ||
  305. strings.HasPrefix(trimmed, "/v1/") ||
  306. strings.HasPrefix(trimmed, "/v1beta/") ||
  307. strings.HasPrefix(trimmed, "/backend-api/") ||
  308. strings.HasPrefix(trimmed, "/antigravity/") ||
  309. strings.HasPrefix(trimmed, "/setup/") ||
  310. trimmed == "/health" ||
  311. trimmed == "/models" ||
  312. trimmed == "/responses" ||
  313. strings.HasPrefix(trimmed, "/responses/") ||
  314. trimmed == "/alpha/search" ||
  315. strings.HasPrefix(trimmed, "/images/") ||
  316. strings.HasPrefix(trimmed, "/videos/")
  317. }
  318. func serveIndexHTML(c *gin.Context, fsys fs.FS) {
  319. file, err := fsys.Open("index.html")
  320. if err != nil {
  321. c.String(http.StatusNotFound, "Frontend not found")
  322. c.Abort()
  323. return
  324. }
  325. defer func() { _ = file.Close() }()
  326. content, err := io.ReadAll(file)
  327. if err != nil {
  328. c.String(http.StatusInternalServerError, "Failed to read index.html")
  329. c.Abort()
  330. return
  331. }
  332. c.Data(http.StatusOK, "text/html; charset=utf-8", content)
  333. c.Abort()
  334. }
  335. func HasEmbeddedFrontend() bool {
  336. _, err := frontendFS.ReadFile("dist/index.html")
  337. return err == nil
  338. }