github.com/vugu/vugu@v0.3.6-0.20240430171613-3f6f402e014b/devutil/file-server.go (about)

     1  package devutil
     2  
     3  import (
     4  	"fmt"
     5  	"log"
     6  	"net/http"
     7  	"net/url"
     8  	"os"
     9  	"path"
    10  	"sort"
    11  	"strings"
    12  	"time"
    13  )
    14  
    15  // FileServer is similar to http.FileServer but has some options and behavior differences more useful for Vugu programs.
    16  // The following rules apply when serving http responses:
    17  //
    18  // If the path is a directory but does not end with a slash it is redirected to be with a slash.
    19  //
    20  // If the path is a directory and ends with a slash then if it contains an index.html file that is served.
    21  //
    22  // If the path is a directory and ends with a slash and has no index.html, if listings are enabled a listing will be returned.
    23  //
    24  // If the path does not exist but exists when .html is appended to it then that file is served.
    25  //
    26  // For anything else the handler for the not-found case is called, or if not set then a 404.html will be searched for and if
    27  // that's not present http.NotFound is called.
    28  //
    29  // Directory listings are disabled by default due to security concerns but can be enabled with SetListings.
    30  type FileServer struct {
    31  	fsys            http.FileSystem
    32  	listings        bool         // do we show directory listings
    33  	notFoundHandler http.Handler // call when not found
    34  }
    35  
    36  // NewFileServer returns a FileServer instance.
    37  // Before using you must set FileSystem to serve from by calling SetFileSystem or SetDir.
    38  func NewFileServer() *FileServer {
    39  	return &FileServer{}
    40  }
    41  
    42  // SetFileSystem sets the FileSystem to use when serving files.
    43  func (fs *FileServer) SetFileSystem(fsys http.FileSystem) *FileServer {
    44  	fs.fsys = fsys
    45  	return fs
    46  }
    47  
    48  // SetDir is short for SetFileSystem(http.Dir(dir))
    49  func (fs *FileServer) SetDir(dir string) *FileServer {
    50  	return fs.SetFileSystem(http.Dir(dir))
    51  }
    52  
    53  // SetListings enables or disables automatic directory listings when a directory is indicated in the URL path.
    54  func (fs *FileServer) SetListings(v bool) *FileServer {
    55  	fs.listings = v
    56  	return fs
    57  }
    58  
    59  // SetNotFoundHandler sets the handle used when no applicable file can be found.
    60  func (fs *FileServer) SetNotFoundHandler(h http.Handler) *FileServer {
    61  	fs.notFoundHandler = h
    62  	return fs
    63  }
    64  
    65  func (fs *FileServer) serveNotFound(w http.ResponseWriter, r *http.Request) {
    66  
    67  	// notFoundHandler takes precedence
    68  	if fs.notFoundHandler != nil {
    69  		fs.notFoundHandler.ServeHTTP(w, r)
    70  		return
    71  	}
    72  
    73  	// check for 404.html
    74  	{
    75  		f, err := fs.fsys.Open("/404.html")
    76  		if err != nil {
    77  			goto defNotFound
    78  		}
    79  		defer f.Close()
    80  		st, err := f.Stat()
    81  		if err != nil {
    82  			goto defNotFound
    83  		}
    84  		w.Header().Set("Content-Type", "text/html; charset=utf-8")
    85  		w.WriteHeader(404)
    86  		http.ServeContent(w, r, r.URL.Path, st.ModTime(), f)
    87  		return
    88  	}
    89  
    90  defNotFound:
    91  	// otherwise fall back to http.NotFound
    92  	http.NotFound(w, r)
    93  }
    94  
    95  // ServeHTTP implements http.Handler with the appropriate behavior.
    96  func (fs *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    97  
    98  	// NOTE: much of this borrowed and adapted from https://golang.org/src/net/http/fs.go
    99  
   100  	upath := r.URL.Path
   101  	if !strings.HasPrefix(upath, "/") {
   102  		upath = "/" + upath
   103  		r.URL.Path = upath
   104  	}
   105  
   106  	const indexPage = "/index.html"
   107  
   108  	// redirect .../index.html to .../
   109  	// can't use Redirect() because that would make the path absolute,
   110  	// which would be a problem running under StripPrefix
   111  	if strings.HasSuffix(r.URL.Path, indexPage) {
   112  		localRedirect(w, r, "./")
   113  		return
   114  	}
   115  
   116  	name := path.Clean("/" + r.URL.Path)
   117  
   118  	f, err := fs.fsys.Open(name)
   119  	if err != nil {
   120  
   121  		// try again with .html
   122  		f2, err2 := fs.fsys.Open(name + ".html")
   123  		if err2 == nil {
   124  			f = f2
   125  		} else {
   126  
   127  			msg, code := toHTTPError(err)
   128  			if code == 404 {
   129  				fs.serveNotFound(w, r)
   130  				return
   131  			}
   132  			http.Error(w, msg, code)
   133  			return
   134  		}
   135  
   136  	}
   137  	defer f.Close()
   138  
   139  	d, err := f.Stat()
   140  	if err != nil {
   141  		msg, code := toHTTPError(err)
   142  		http.Error(w, msg, code)
   143  		return
   144  	}
   145  
   146  	// redirect to canonical path: / at end of directory url
   147  	// r.URL.Path always begins with /
   148  	url := r.URL.Path
   149  	if d.IsDir() {
   150  		if url[len(url)-1] != '/' {
   151  			localRedirect(w, r, path.Base(url)+"/")
   152  			return
   153  		}
   154  	} else {
   155  		if url[len(url)-1] == '/' {
   156  			localRedirect(w, r, "../"+path.Base(url))
   157  			return
   158  		}
   159  	}
   160  
   161  	if d.IsDir() {
   162  
   163  		url := r.URL.Path
   164  		// redirect if the directory name doesn't end in a slash
   165  		if url == "" || url[len(url)-1] != '/' {
   166  			localRedirect(w, r, path.Base(url)+"/")
   167  			return
   168  		}
   169  
   170  		// use contents of index.html for directory, if present
   171  		index := strings.TrimSuffix(name, "/") + indexPage
   172  		ff, err := fs.fsys.Open(index)
   173  		if err == nil {
   174  			defer ff.Close()
   175  			dd, err := ff.Stat()
   176  			if err == nil {
   177  				d = dd
   178  				f = ff
   179  			}
   180  		} else {
   181  			// no index.html found for directory
   182  			if !fs.listings {
   183  				fs.serveNotFound(w, r)
   184  				return
   185  			}
   186  		}
   187  	}
   188  
   189  	// Still a directory? (we didn't find an index.html file)
   190  	if fs.listings && d.IsDir() {
   191  		if checkIfModifiedSince(r, d.ModTime()) == condFalse {
   192  			writeNotModified(w)
   193  			return
   194  		}
   195  		setLastModified(w, d.ModTime())
   196  		dirList(w, r, f)
   197  		return
   198  	}
   199  
   200  	// serveContent will check modification time
   201  	// sizeFunc := func() (int64, error) { return d.Size(), nil }
   202  	// serveContent(w, r, d.Name(), d.ModTime(), sizeFunc, f)
   203  
   204  	// log.Printf("about to serve: f=%#v, d=%#v", f, d)
   205  
   206  	http.ServeContent(w, r, d.Name(), d.ModTime(), f)
   207  }
   208  
   209  // localRedirect gives a Moved Permanently response.
   210  // It does not convert relative paths to absolute paths like Redirect does.
   211  func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
   212  	if q := r.URL.RawQuery; q != "" {
   213  		newPath += "?" + q
   214  	}
   215  	w.Header().Set("Location", newPath)
   216  	w.WriteHeader(http.StatusMovedPermanently)
   217  }
   218  
   219  // toHTTPError returns a non-specific HTTP error message and status code
   220  // for a given non-nil error value. It's important that toHTTPError does not
   221  // actually return err.Error(), since msg and httpStatus are returned to users,
   222  // and historically Go's ServeContent always returned just "404 Not Found" for
   223  // all errors. We don't want to start leaking information in error messages.
   224  func toHTTPError(err error) (msg string, httpStatus int) {
   225  	if os.IsNotExist(err) {
   226  		return "404 page not found", http.StatusNotFound
   227  	}
   228  	if os.IsPermission(err) {
   229  		return "403 Forbidden", http.StatusForbidden
   230  	}
   231  	// Default:
   232  	return "500 Internal Server Error", http.StatusInternalServerError
   233  }
   234  
   235  // condResult is the result of an HTTP request precondition check.
   236  // See https://tools.ietf.org/html/rfc7232 section 3.
   237  type condResult int
   238  
   239  const (
   240  	condNone condResult = iota
   241  	condTrue
   242  	condFalse
   243  )
   244  
   245  func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
   246  	if r.Method != "GET" && r.Method != "HEAD" {
   247  		return condNone
   248  	}
   249  	ims := r.Header.Get("If-Modified-Since")
   250  	if ims == "" || isZeroTime(modtime) {
   251  		return condNone
   252  	}
   253  	t, err := http.ParseTime(ims)
   254  	if err != nil {
   255  		return condNone
   256  	}
   257  	// The Last-Modified header truncates sub-second precision so
   258  	// the modtime needs to be truncated too.
   259  	modtime = modtime.Truncate(time.Second)
   260  	if modtime.Before(t) || modtime.Equal(t) {
   261  		return condFalse
   262  	}
   263  	return condTrue
   264  }
   265  
   266  func writeNotModified(w http.ResponseWriter) {
   267  	// RFC 7232 section 4.1:
   268  	// a sender SHOULD NOT generate representation metadata other than the
   269  	// above listed fields unless said metadata exists for the purpose of
   270  	// guiding cache updates (e.g., Last-Modified might be useful if the
   271  	// response does not have an ETag field).
   272  	h := w.Header()
   273  	delete(h, "Content-Type")
   274  	delete(h, "Content-Length")
   275  	if h.Get("Etag") != "" {
   276  		delete(h, "Last-Modified")
   277  	}
   278  	w.WriteHeader(http.StatusNotModified)
   279  }
   280  
   281  func setLastModified(w http.ResponseWriter, modtime time.Time) {
   282  	if !isZeroTime(modtime) {
   283  		w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
   284  	}
   285  }
   286  
   287  var unixEpochTime = time.Unix(0, 0)
   288  
   289  // isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
   290  func isZeroTime(t time.Time) bool {
   291  	return t.IsZero() || t.Equal(unixEpochTime)
   292  }
   293  
   294  func dirList(w http.ResponseWriter, r *http.Request, f http.File) {
   295  	dirs, err := f.Readdir(-1)
   296  	if err != nil {
   297  		log.Print(r, "http: error reading directory: %v", err)
   298  		http.Error(w, "Error reading directory", http.StatusInternalServerError)
   299  		return
   300  	}
   301  	sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() })
   302  
   303  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
   304  	fmt.Fprintf(w, "<pre>\n")
   305  	for _, d := range dirs {
   306  		name := d.Name()
   307  		if d.IsDir() {
   308  			name += "/"
   309  		}
   310  		// name may contain '?' or '#', which must be escaped to remain
   311  		// part of the URL path, and not indicate the start of a query
   312  		// string or fragment.
   313  		url := url.URL{Path: name}
   314  		fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", url.String(), htmlReplacer.Replace(name))
   315  	}
   316  	fmt.Fprintf(w, "</pre>\n")
   317  }
   318  
   319  var htmlReplacer = strings.NewReplacer(
   320  	"&", "&amp;",
   321  	"<", "&lt;",
   322  	">", "&gt;",
   323  
   324  	`"`, "&#34;",
   325  
   326  	"'", "&#39;",
   327  )
   328  
   329  // ----------------------------------
   330  // old notes:
   331  
   332  // contentFunc     func(fs http.FileSystem, name string) (modtime time.Time, content io.ReadSeeker, err error) // can handle various request path transformations
   333  
   334  // SetContentFunc assigns the function that will
   335  // func (fs *FileServer) SetContentFunc(f func(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error)) {
   336  
   337  // }
   338  
   339  // // DefaultContentFunc serves files directly from a the filesystem with the following additional logic:
   340  // // If the path is a directory but does not end with a slash it is redirected to be with a slash.
   341  // // If the path is a directory and ends with a slash then if it contains an index.html file that is served.
   342  // // If the path does not exist but exists when .html is appended to it then that file is served.
   343  // // For anything else the error returned from fs.Open is returned.
   344  // func DefaultContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) {
   345  
   346  // }
   347  
   348  // // DefaultListingContentFunc is like DefaultContentFunc but with directory listings enabled.
   349  // func DefaultListingContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) {
   350  // }
   351  
   352  // // ReadSeekCloser has Read, Seek and Close methods.
   353  // type ReadSeekCloser interface {
   354  // 	io.Reader
   355  // 	io.Seeker
   356  // 	io.Closer
   357  // }
   358  
   359  // what about /anything mapping to index page
   360  // (seems like an option to me - maybe need some func to map this stuff
   361  // plus convenience methods for common cases)
   362  
   363  // pick a sensible default