package web import ( "embed" "io" "io/fs" "net/http" "path" "path/filepath" "strings" "time" ) // NOTE: Vite outputs to web/dist with assets in dist/assets. // If you add more nested folders in the future, include them here too. //go:embed dist var distFS embed.FS // spaFileSystem serves embedded dist/ files with SPA fallback to index.html type spaFileSystem struct { fs fs.FS } func (s spaFileSystem) Open(name string) (fs.File, error) { // Normalize, strip leading slash if strings.HasPrefix(name, "/") { name = name[1:] } // Try exact file f, err := s.fs.Open(name) if err == nil { return f, nil } // If the requested file doesn't exist, fall back to index.html for SPA routes // BUT only if it's not obviously a static asset extension ext := strings.ToLower(filepath.Ext(name)) switch ext { case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".br", ".gz": return nil, fs.ErrNotExist } return s.fs.Open("index.html") } func newDistFS() (fs.FS, error) { return fs.Sub(distFS, "dist") } // SPAHandler returns an http.Handler that serves the embedded UI (with caching) func SPAHandler() (http.Handler, error) { sub, err := newDistFS() if err != nil { return nil, err } spa := spaFileSystem{fs: sub} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/swagger") || strings.HasPrefix(r.URL.Path, "/db-studio") || strings.HasPrefix(r.URL.Path, "/debug/pprof") { http.NotFound(w, r) return } raw := strings.TrimSpace(r.URL.Path) if raw == "" || raw == "/" { raw = "/index.html" } clean := path.Clean("/" + raw) // nosemgrep: autoglue.filesystem.no-path-clean filePath := strings.TrimPrefix(clean, "/") if filePath == "" { filePath = "index.html" } // Try compressed variants for assets and HTML // NOTE: we only change *Content-Encoding*; Content-Type derives from original ext // Always vary on Accept-Encoding w.Header().Add("Vary", "Accept-Encoding") enc := r.Header.Get("Accept-Encoding") if tryServeCompressed(w, r, spa, filePath, enc) { return } // Fallback: normal open (or SPA fallback) f, err := spa.Open(filePath) if err != nil { http.NotFound(w, r) return } defer f.Close() if strings.HasSuffix(filePath, ".html") { w.Header().Set("Cache-Control", "no-cache") } else { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } info, _ := f.Stat() modTime := time.Now() if info != nil { modTime = info.ModTime() } http.ServeContent(w, r, filePath, modTime, file{f}) }), nil } func tryServeCompressed(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, enc string) bool { wantsBR := strings.Contains(enc, "br") wantsGZ := strings.Contains(enc, "gzip") type cand struct { logical string // MIME/type decision uses this (uncompressed name) physical string // actual file we open (with .br/.gz) enc string } var cands []cand // 1) direct compressed variant of requested path (rare for SPA routes, but cheap to try) if wantsBR { cands = append(cands, cand{logical: filePath, physical: filePath + ".br", enc: "br"}) } if wantsGZ { cands = append(cands, cand{logical: filePath, physical: filePath + ".gz", enc: "gzip"}) } // 2) SPA route: fall back to compressed index.html if filepath.Ext(filePath) == "" { if wantsBR { cands = append(cands, cand{logical: "index.html", physical: "index.html.br", enc: "br"}) } if wantsGZ { cands = append(cands, cand{logical: "index.html", physical: "index.html.gz", enc: "gzip"}) } } for _, c := range cands { f, err := spa.fs.Open(c.physical) // open EXACT path so we don't accidentally get SPA fallback if err != nil { continue } defer f.Close() // Cache headers if strings.HasSuffix(c.logical, ".html") { w.Header().Set("Cache-Control", "no-cache") } else { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } if ct := mimeByExt(path.Ext(c.logical)); ct != "" { w.Header().Set("Content-Type", ct) } w.Header().Set("Content-Encoding", c.enc) w.Header().Add("Vary", "Accept-Encoding") info, _ := f.Stat() modTime := time.Now() if info != nil { modTime = info.ModTime() } // Serve the precompressed bytes http.ServeContent(w, r, c.physical, modTime, file{f}) return true } return false } func serveIfExists(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, ext, encoding string) bool { cf := filePath + ext f, err := spa.Open(cf) if err != nil { return false } defer f.Close() // Set caching headers if strings.HasSuffix(filePath, ".html") { w.Header().Set("Cache-Control", "no-cache") } else { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } // Preserve original content type by extension of *uncompressed* file if ct := mimeByExt(path.Ext(filePath)); ct != "" { w.Header().Set("Content-Type", ct) } w.Header().Set("Content-Encoding", encoding) info, _ := f.Stat() modTime := time.Now() if info != nil { modTime = info.ModTime() } // Serve the compressed bytes as an io.ReadSeeker if possible http.ServeContent(w, r, cf, modTime, file{f}) return true } func mimeByExt(ext string) string { switch strings.ToLower(ext) { case ".html": return "text/html; charset=utf-8" case ".js": return "application/javascript" case ".css": return "text/css; charset=utf-8" case ".json": return "application/json" case ".svg": return "image/svg+xml" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".webp": return "image/webp" case ".ico": return "image/x-icon" case ".woff2": return "font/woff2" case ".woff": return "font/woff" default: return "" // let Go sniff if empty } } // file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent) type file struct{ fs.File } func (f file) Seek(offset int64, whence int) (int64, error) { if s, ok := f.File.(io.Seeker); ok { return s.Seek(offset, whence) } // Fallback: not seekable return 0, fs.ErrInvalid }