diff options
Diffstat (limited to 'internal/server/fileserver.go')
| -rw-r--r-- | internal/server/fileserver.go | 111 |
1 files changed, 111 insertions, 0 deletions
diff --git a/internal/server/fileserver.go b/internal/server/fileserver.go new file mode 100644 index 0000000..d231519 --- /dev/null +++ b/internal/server/fileserver.go @@ -0,0 +1,111 @@ +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) + } +} |
