package static import ( "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" ) // Register attaches the static file handler to the Gin router. // distDir is the path to frontend/dist/. func Register(r *gin.Engine, distDir string) { r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // Skip API routes – they're handled elsewhere if strings.HasPrefix(path, "/api/") { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } // Try to serve a real file fsPath := filepath.Join(distDir, filepath.FromSlash(path)) if info, err := os.Stat(fsPath); err == nil && !info.IsDir() { serveFile(c, fsPath) return } // SPA fallback: serve index.html indexPath := filepath.Join(distDir, "index.html") if _, err := os.Stat(indexPath); err != nil { c.String(http.StatusNotFound, "frontend not built – run npm run build:web in frontend/") return } c.Header("Cache-Control", "no-cache, no-store, must-revalidate") c.File(indexPath) }) } func serveFile(c *gin.Context, fsPath string) { name := filepath.Base(fsPath) ext := strings.ToLower(filepath.Ext(name)) if name == "index.html" || strings.HasSuffix(name, ".webmanifest") { c.Header("Cache-Control", "no-cache, no-store, must-revalidate") } else if isHashedAsset(name, ext) { c.Header("Cache-Control", "public, max-age=31536000, immutable") } else if ext == ".woff" || ext == ".woff2" || ext == ".ttf" || ext == ".otf" { c.Header("Cache-Control", "public, max-age=31536000, immutable") } else { c.Header("Cache-Control", "public, max-age=3600") } // Gin's File() handles Content-Type and ETag automatically c.File(fsPath) } // isHashedAsset returns true if the filename looks like a Vite hashed asset // (e.g. index-BxYt3k2a.js, chunk-abc123.css). func isHashedAsset(name, ext string) bool { switch ext { case ".js", ".css", ".mjs": return strings.Contains(name, "-") || strings.Contains(name, ".") } return false }