package rest import ( "fmt" "io" "net/http" "os" "path/filepath" "strings" ) // FS provides http.FileServer handler to serve static files from a http.FileSystem, // prevents directory listing by default and supports spa-friendly mode (off by default) returning /index.html on 404. // - public defines base path of the url, i.e. for http://example.com/static/* it should be /static // - local for the local path to the root of the served directory // - notFound is the reader for the custom 404 html, can be nil for default type FS struct { public, root string notFound io.Reader isSpa bool enableListing bool handler http.HandlerFunc } // NewFileServer creates file server with optional spa mode and optional direcroty listing (disabled by default) func NewFileServer(public, local string, options ...FsOpt) (*FS, error) { res := FS{ public: public, notFound: nil, isSpa: false, enableListing: false, } root, err := filepath.Abs(local) if err != nil { return nil, fmt.Errorf("can't get absolute path for %s: %w", local, err) } res.root = root if _, err = os.Stat(root); os.IsNotExist(err) { return nil, fmt.Errorf("local path %s doesn't exist: %w", root, err) } for _, opt := range options { err = opt(&res) if err != nil { return nil, err } } cfs := customFS{ fs: http.Dir(root), spa: res.isSpa, listing: res.enableListing, } f := http.StripPrefix(public, http.FileServer(cfs)) res.handler = func(w http.ResponseWriter, r *http.Request) { f.ServeHTTP(w, r) } if !res.enableListing { h, err := custom404Handler(f, res.notFound) if err != nil { return nil, err } res.handler = func(w http.ResponseWriter, r *http.Request) { h.ServeHTTP(w, r) } } return &res, nil } // FileServer is a shortcut for making FS with listing disabled and the custom noFound reader (can be nil). // Deprecated: the method is for back-compatibility only and user should use the universal NewFileServer instead func FileServer(public, local string, notFound io.Reader) (http.Handler, error) { return NewFileServer(public, local, FsOptCustom404(notFound)) } // FileServerSPA is a shortcut for making FS with SPA-friendly handling of 404, listing disabled and the custom noFound reader (can be nil). // Deprecated: the method is for back-compatibility only and user should use the universal NewFileServer instead func FileServerSPA(public, local string, notFound io.Reader) (http.Handler, error) { return NewFileServer(public, local, FsOptCustom404(notFound), FsOptSPA) } // ServeHTTP makes FileServer compatible with http.Handler interface func (fs *FS) ServeHTTP(w http.ResponseWriter, r *http.Request) { fs.handler(w, r) } // FsOpt defines functional option type type FsOpt func(fs *FS) error // FsOptSPA turns on SPA mode returning "/index.html" on not-found func FsOptSPA(fs *FS) error { fs.isSpa = true return nil } // FsOptListing turns on directory listing func FsOptListing(fs *FS) error { fs.enableListing = true return nil } // FsOptCustom404 sets custom 404 reader func FsOptCustom404(fr io.Reader) FsOpt { return func(fs *FS) error { fs.notFound = fr return nil } } // customFS wraps http.FileSystem with spa and no-listing optional support type customFS struct { fs http.FileSystem spa bool listing bool } // Open file on FS, for directory enforce index.html and fail on a missing index func (cfs customFS) Open(name string) (http.File, error) { f, err := cfs.fs.Open(name) if err != nil { if cfs.spa { return cfs.fs.Open("/index.html") } return nil, err } finfo, err := f.Stat() if err != nil { return nil, err } if finfo.IsDir() { index := strings.TrimSuffix(name, "/") + "/index.html" if _, err := cfs.fs.Open(index); err == nil { // index.html will be served if found return f, nil } // no index.html in directory if !cfs.listing { // listing disabled if _, err := cfs.fs.Open(index); err != nil { return nil, err } } } return f, nil } // respWriter404 intercept Write to provide custom 404 response type respWriter404 struct { http.ResponseWriter status int msg []byte } func (w *respWriter404) WriteHeader(status int) { w.status = status if status == http.StatusNotFound { w.Header().Set("Content-Type", "text/html; charset=utf-8") } w.ResponseWriter.WriteHeader(status) } func (w *respWriter404) Write(p []byte) (n int, err error) { if w.status != http.StatusNotFound || w.msg == nil { return w.ResponseWriter.Write(p) } _, err = w.ResponseWriter.Write(w.msg) return len(p), err } func custom404Handler(next http.Handler, notFound io.Reader) (http.Handler, error) { if notFound == nil { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }), nil } body, err := io.ReadAll(notFound) if err != nil { return nil, err } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(&respWriter404{ResponseWriter: w, msg: body}, r) }), nil }