package server import ( "io/fs" "mime" "net/http" "os" "path" "path/filepath" "strings" "github.com/gin-gonic/gin" ) // AllowedMIMEs maps file extensions (including dot) to Content-Type values. // A nil map means any extension is allowed; MIME type is detected automatically. type AllowedMIMEs map[string]string // FileHandler returns a Gin HandlerFunc that serves files from the given root directory. // The wildcard param name is *filepath. func FileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc { return func(c *gin.Context) { filepath := c.Param("filepath") serveFile(c, root, filepath, allowed) } } // PublicFileHandler returns a Gin HandlerFunc that serves files using the request URL path. // Used for NoRoute fallback to serve public static files. func PublicFileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc { return func(c *gin.Context) { filepath := c.Request.URL.Path serveFile(c, root, filepath, allowed) } } func serveFile(c *gin.Context, root, requestPath string, allowed AllowedMIMEs) { // Clean the path to prevent directory traversal clean := path.Clean(requestPath) if strings.Contains(clean, "..") { c.AbortWithStatus(http.StatusNotFound) return } // Remove leading slash for filepath.Join clean = strings.TrimPrefix(clean, "/") // Map empty path or directory to index.html (clean URLs) if clean == "" || clean == "." { clean = "index.html" } fullPath := filepath.Join(root, clean) // Check file existence; if directory, try index.html inside it info, err := os.Stat(fullPath) if err == nil && info.IsDir() { fullPath = filepath.Join(fullPath, "index.html") info, err = os.Stat(fullPath) } if err != nil { // Try appending .html for clean URLs (e.g. /about -> /about.html) htmlPath := fullPath + ".html" if info2, err2 := os.Stat(htmlPath); err2 == nil && !info2.IsDir() { fullPath = htmlPath info = info2 err = nil } } if err != nil || info.IsDir() { c.AbortWithStatus(http.StatusNotFound) return } // Determine MIME type ext := filepath.Ext(fullPath) var mimeType string if allowed != nil { var ok bool mimeType, ok = allowed[ext] if !ok { c.AbortWithStatus(http.StatusNotFound) return } } else { mimeType = mime.TypeByExtension(ext) if mimeType == "" { mimeType = "application/octet-stream" } } // Serve the file with proper MIME type c.Header("Content-Type", mimeType) c.File(fullPath) } // EmbedHandler serves files from an embedded (or any) fs.FS. // root is the subdirectory within fsys to serve from. // The gin wildcard param must be named *filepath. func EmbedHandler(fsys fs.FS, root string) gin.HandlerFunc { sub, err := fs.Sub(fsys, root) if err != nil { panic("EmbedHandler: " + err.Error()) } fileServer := http.FileServer(http.FS(sub)) return func(c *gin.Context) { req := c.Request.Clone(c.Request.Context()) req.URL.Path = c.Param("filepath") fileServer.ServeHTTP(c.Writer, req) } }