package ginx import ( "io/fs" "log/slog" "net/http" "os" "path" "strings" "github.com/gin-gonic/gin" ) type noDirFS struct { fs http.FileSystem } type DirOptions struct { ShowList bool SPA bool IndexName string } type SPAOptions struct { Root string EmbeddedFS fs.FS EmbeddedRoot string DirOptions DirOptions NotFoundPrefixes []string NotFoundHandler gin.HandlerFunc // RootRedirect, when set, answers "/" with a redirect instead of serving // the SPA. The static export has no index.html at the root, so without this // the site origin answers 404. RootRedirect string } func StaticFiles(root string) http.FileSystem { return noDirFS{fs: http.Dir(root)} } func (n noDirFS) Open(name string) (http.File, error) { file, err := n.fs.Open(name) if err != nil { return nil, err } info, err := file.Stat() if err != nil { _ = file.Close() return nil, err } if info.IsDir() { _ = file.Close() return nil, os.ErrNotExist } return file, nil } func HandleSPA(engine *gin.Engine, options SPAOptions) gin.HandlerFunc { handler := NewSPAHandler(options.Root, options.EmbeddedFS, options.EmbeddedRoot, options.DirOptions) rootHandler := handler if options.RootRedirect != "" { target := options.RootRedirect rootHandler = func(ctx *gin.Context) { ctx.Redirect(http.StatusFound, target) } } engine.GET("/", rootHandler) engine.HEAD("/", rootHandler) engine.NoRoute(func(ctx *gin.Context) { for _, prefix := range options.NotFoundPrefixes { if strings.HasPrefix(ctx.Request.URL.Path, prefix) { if options.NotFoundHandler != nil { options.NotFoundHandler(ctx) return } ctx.AbortWithStatus(http.StatusNotFound) return } } handler(ctx) }) return handler } func NewSPAHandler(root string, embeddedFS fs.FS, embeddedRoot string, options DirOptions) gin.HandlerFunc { if _, err := os.Stat(path.Join(root, options.IndexName)); err == nil { return DirHandler(http.Dir(root), options) } spaFS, err := fs.Sub(embeddedFS, embeddedRoot) if err != nil { slog.Error("failed to load embedded SPA files", slog.Any("err", err)) return func(ctx *gin.Context) { ctx.AbortWithStatus(http.StatusInternalServerError) } } return DirHandler(http.FS(spaFS), options) } func tryOpenFile(fileSystem http.FileSystem, filePath string) (http.File, os.FileInfo, bool) { file, err := fileSystem.Open(filePath) if err != nil { return nil, nil, false } info, err := file.Stat() if err != nil || info.IsDir() { _ = file.Close() return nil, nil, false } return file, info, true } func DirHandler(fileSystem http.FileSystem, options DirOptions) gin.HandlerFunc { return func(ctx *gin.Context) { name := path.Clean(ctx.Request.URL.Path) if name == "." || name == "/" { name = "/" + options.IndexName } file, err := fileSystem.Open(name) if err == nil { defer file.Close() info, err := file.Stat() if err != nil { ctx.AbortWithStatus(http.StatusInternalServerError) return } if info.IsDir() { if options.IndexName != "" { indexName := path.Join(name, options.IndexName) if indexFile, indexInfo, ok := tryOpenFile(fileSystem, indexName); ok { defer indexFile.Close() serveFile(ctx, indexName, indexFile, indexInfo) return } } // Support Next.js static export where directory name matches page html (e.g. /dashboard -> /dashboard.html) htmlName := strings.TrimRight(name, "/") + ".html" if htmlFile, htmlInfo, ok := tryOpenFile(fileSystem, htmlName); ok { defer htmlFile.Close() serveFile(ctx, htmlName, htmlFile, htmlInfo) return } if !options.ShowList { if options.SPA && options.IndexName != "" { indexName := "/" + options.IndexName if indexFile, indexInfo, ok := tryOpenFile(fileSystem, indexName); ok { defer indexFile.Close() serveFile(ctx, indexName, indexFile, indexInfo) return } } ctx.AbortWithStatus(http.StatusNotFound) return } } else { serveFile(ctx, name, file, info) return } } else if !os.IsNotExist(err) { ctx.AbortWithStatus(http.StatusInternalServerError) return } // Try name.html (for sub-routes like /dashboard/login -> /dashboard/login.html) htmlName := strings.TrimRight(name, "/") + ".html" if htmlFile, htmlInfo, ok := tryOpenFile(fileSystem, htmlName); ok { defer htmlFile.Close() serveFile(ctx, htmlName, htmlFile, htmlInfo) return } if options.SPA && options.IndexName != "" { indexName := "/" + options.IndexName if indexFile, indexInfo, ok := tryOpenFile(fileSystem, indexName); ok { defer indexFile.Close() serveFile(ctx, indexName, indexFile, indexInfo) return } } ctx.AbortWithStatus(http.StatusNotFound) } } func serveFile(ctx *gin.Context, name string, file http.File, info os.FileInfo) { http.ServeContent(ctx.Writer, ctx.Request, name, info.ModTime(), file) }