github.com/vugu/vugu@v0.3.5/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  				name = index
   178  				d = dd
   179  				f = ff
   180  			}
   181  		} else {
   182  			// no index.html found for directory
   183  			if !fs.listings {
   184  				fs.serveNotFound(w, r)
   185  				return
   186  			}
   187  		}
   188  	}
   189  
   190  	// Still a directory? (we didn't find an index.html file)
   191  	if fs.listings && d.IsDir() {
   192  		if checkIfModifiedSince(r, d.ModTime()) == condFalse {
   193  			writeNotModified(w)
   194  			return
   195  		}
   196  		setLastModified(w, d.ModTime())
   197  		dirList(w, r, f)
   198  		return
   199  	}
   200  
   201  	// serveContent will check modification time
   202  	// sizeFunc := func() (int64, error) { return d.Size(), nil }
   203  	// serveContent(w, r, d.Name(), d.ModTime(), sizeFunc, f)
   204  
   205  	// log.Printf("about to serve: f=%#v, d=%#v", f, d)
   206  
   207  	http.ServeContent(w, r, d.Name(), d.ModTime(), f)
   208  }
   209  
   210  // localRedirect gives a Moved Permanently response.
   211  // It does not convert relative paths to absolute paths like Redirect does.
   212  func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
   213  	if q := r.URL.RawQuery; q != "" {
   214  		newPath += "?" + q
   215  	}
   216  	w.Header().Set("Location", newPath)
   217  	w.WriteHeader(http.StatusMovedPermanently)
   218  }
   219  
   220  // toHTTPError returns a non-specific HTTP error message and status code
   221  // for a given non-nil error value. It's important that toHTTPError does not
   222  // actually return err.Error(), since msg and httpStatus are returned to users,
   223  // and historically Go's ServeContent always returned just "404 Not Found" for
   224  // all errors. We don't want to start leaking information in error messages.
   225  func toHTTPError(err error) (msg string, httpStatus int) {
   226  	if os.IsNotExist(err) {
   227  		return "404 page not found", http.StatusNotFound
   228  	}
   229  	if os.IsPermission(err) {
   230  		return "403 Forbidden", http.StatusForbidden
   231  	}
   232  	// Default:
   233  	return "500 Internal Server Error", http.StatusInternalServerError
   234  }
   235  
   236  // condResult is the result of an HTTP request precondition check.
   237  // See https://tools.ietf.org/html/rfc7232 section 3.
   238  type condResult int
   239  
   240  const (
   241  	condNone condResult = iota
   242  	condTrue
   243  	condFalse
   244  )
   245  
   246  func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
   247  	if r.Method != "GET" && r.Method != "HEAD" {
   248  		return condNone
   249  	}
   250  	ims := r.Header.Get("If-Modified-Since")
   251  	if ims == "" || isZeroTime(modtime) {
   252  		return condNone
   253  	}
   254  	t, err := http.ParseTime(ims)
   255  	if err != nil {
   256  		return condNone
   257  	}
   258  	// The Last-Modified header truncates sub-second precision so
   259  	// the modtime needs to be truncated too.
   260  	modtime = modtime.Truncate(time.Second)
   261  	if modtime.Before(t) || modtime.Equal(t) {
   262  		return condFalse
   263  	}
   264  	return condTrue
   265  }
   266  
   267  func writeNotModified(w http.ResponseWriter) {
   268  	// RFC 7232 section 4.1:
   269  	// a sender SHOULD NOT generate representation metadata other than the
   270  	// above listed fields unless said metadata exists for the purpose of
   271  	// guiding cache updates (e.g., Last-Modified might be useful if the
   272  	// response does not have an ETag field).
   273  	h := w.Header()
   274  	delete(h, "Content-Type")
   275  	delete(h, "Content-Length")
   276  	if h.Get("Etag") != "" {
   277  		delete(h, "Last-Modified")
   278  	}
   279  	w.WriteHeader(http.StatusNotModified)
   280  }
   281  
   282  func setLastModified(w http.ResponseWriter, modtime time.Time) {
   283  	if !isZeroTime(modtime) {
   284  		w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
   285  	}
   286  }
   287  
   288  var unixEpochTime = time.Unix(0, 0)
   289  
   290  // isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
   291  func isZeroTime(t time.Time) bool {
   292  	return t.IsZero() || t.Equal(unixEpochTime)
   293  }
   294  
   295  func dirList(w http.ResponseWriter, r *http.Request, f http.File) {
   296  	dirs, err := f.Readdir(-1)
   297  	if err != nil {
   298  		log.Print(r, "http: error reading directory: %v", err)
   299  		http.Error(w, "Error reading directory", http.StatusInternalServerError)
   300  		return
   301  	}
   302  	sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() })
   303  
   304  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
   305  	fmt.Fprintf(w, "<pre>\n")
   306  	for _, d := range dirs {
   307  		name := d.Name()
   308  		if d.IsDir() {
   309  			name += "/"
   310  		}
   311  		// name may contain '?' or '#', which must be escaped to remain
   312  		// part of the URL path, and not indicate the start of a query
   313  		// string or fragment.
   314  		url := url.URL{Path: name}
   315  		fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", url.String(), htmlReplacer.Replace(name))
   316  	}
   317  	fmt.Fprintf(w, "</pre>\n")
   318  }
   319  
   320  var htmlReplacer = strings.NewReplacer(
   321  	"&", "&amp;",
   322  	"<", "&lt;",
   323  	">", "&gt;",
   324  
   325  	`"`, "&#34;",
   326  
   327  	"'", "&#39;",
   328  )
   329  
   330  // ----------------------------------
   331  // old notes:
   332  
   333  // contentFunc     func(fs http.FileSystem, name string) (modtime time.Time, content io.ReadSeeker, err error) // can handle various request path transformations
   334  
   335  // SetContentFunc assigns the function that will
   336  // func (fs *FileServer) SetContentFunc(f func(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error)) {
   337  
   338  // }
   339  
   340  // // DefaultContentFunc serves files directly from a the filesystem with the following additional logic:
   341  // // If the path is a directory but does not end with a slash it is redirected to be with a slash.
   342  // // If the path is a directory and ends with a slash then if it contains an index.html file that is served.
   343  // // If the path does not exist but exists when .html is appended to it then that file is served.
   344  // // For anything else the error returned from fs.Open is returned.
   345  // func DefaultContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) {
   346  
   347  // }
   348  
   349  // // DefaultListingContentFunc is like DefaultContentFunc but with directory listings enabled.
   350  // func DefaultListingContentFunc(fs http.FileSystem, name string) (modtime time.Time, content ReadSeekCloser, err error) {
   351  // }
   352  
   353  // // ReadSeekCloser has Read, Seek and Close methods.
   354  // type ReadSeekCloser interface {
   355  // 	io.Reader
   356  // 	io.Seeker
   357  // 	io.Closer
   358  // }
   359  
   360  // what about /anything mapping to index page
   361  // (seems like an option to me - maybe need some func to map this stuff
   362  // plus convenience methods for common cases)
   363  
   364  // pick a sensible default