github.com/wader/devd@v0.0.0-20221031103345-441c7e455249/fileserver/fileserver.go (about)

     1  // Package fileserver provides a filesystem HTTP handler, based on the built-in
     2  // Go FileServer. Extensions include better directory listings, support for
     3  // injection, better and use of Context.
     4  package fileserver
     5  
     6  import (
     7  	"errors"
     8  	"fmt"
     9  	"html/template"
    10  	"io"
    11  	"mime"
    12  	"net/http"
    13  	"os"
    14  	"path"
    15  	"path/filepath"
    16  	"sort"
    17  	"strconv"
    18  	"strings"
    19  	"time"
    20  
    21  	"golang.org/x/net/context"
    22  
    23  	"github.com/cortesi/termlog"
    24  	"github.com/wader/devd/inject"
    25  	"github.com/wader/devd/routespec"
    26  )
    27  
    28  const sniffLen = 512
    29  
    30  func rawHeaderGet(h http.Header, key string) string {
    31  	if v := h[key]; len(v) > 0 {
    32  		return v[0]
    33  	}
    34  	return ""
    35  }
    36  
    37  // fileSlice implements sort.Interface, which allows to sort by file name with
    38  // directories first.
    39  type fileSlice []os.FileInfo
    40  
    41  func (p fileSlice) Len() int {
    42  	return len(p)
    43  }
    44  
    45  func (p fileSlice) Less(i, j int) bool {
    46  	a, b := p[i], p[j]
    47  	if a.IsDir() && !b.IsDir() {
    48  		return true
    49  	}
    50  	if b.IsDir() && !a.IsDir() {
    51  		return false
    52  	}
    53  	if strings.HasPrefix(a.Name(), ".") && !strings.HasPrefix(b.Name(), ".") {
    54  		return false
    55  	}
    56  	if strings.HasPrefix(b.Name(), ".") && !strings.HasPrefix(a.Name(), ".") {
    57  		return true
    58  	}
    59  	return a.Name() < b.Name()
    60  }
    61  
    62  func (p fileSlice) Swap(i, j int) {
    63  	p[i], p[j] = p[j], p[i]
    64  }
    65  
    66  type dirData struct {
    67  	Version string
    68  	Name    string
    69  	Files   fileSlice
    70  }
    71  
    72  type fourohfourData struct {
    73  	Version string
    74  }
    75  
    76  func stripPrefix(prefix string, path string) string {
    77  	if prefix == "" {
    78  		return path
    79  	}
    80  	if p := strings.TrimPrefix(path, prefix); len(p) < len(path) {
    81  		return p
    82  	}
    83  	return path
    84  }
    85  
    86  // errSeeker is returned by ServeContent's sizeFunc when the content
    87  // doesn't seek properly. The underlying Seeker's error text isn't
    88  // included in the sizeFunc reply so it's not sent over HTTP to end
    89  // users.
    90  var errSeeker = errors.New("seeker can't seek")
    91  
    92  // if name is empty, filename is unknown. (used for mime type, before sniffing)
    93  // if modtime.IsZero(), modtime is unknown.
    94  // content must be seeked to the beginning of the file.
    95  // The sizeFunc is called at most once. Its error, if any, is sent in the HTTP response.
    96  func serveContent(ci inject.CopyInject, w http.ResponseWriter, r *http.Request, name string, modtime time.Time, sizeFunc func() (int64, error), content io.ReadSeeker) error {
    97  	if checkLastModified(w, r, modtime) {
    98  		return nil
    99  	}
   100  	done := checkETag(w, r)
   101  	if done {
   102  		return nil
   103  	}
   104  
   105  	code := http.StatusOK
   106  
   107  	// If Content-Type isn't set, use the file's extension to find it, but
   108  	// if the Content-Type is unset explicitly, do not sniff the type.
   109  	ctypes, haveType := w.Header()["Content-Type"]
   110  	var ctype string
   111  	if !haveType {
   112  		ctype = mime.TypeByExtension(filepath.Ext(name))
   113  		if ctype == "" {
   114  			// read a chunk to decide between utf-8 text and binary
   115  			var buf [sniffLen]byte
   116  			n, _ := io.ReadFull(content, buf[:])
   117  			ctype = http.DetectContentType(buf[:n])
   118  			_, err := content.Seek(0, os.SEEK_SET) // rewind to output whole file
   119  			if err != nil {
   120  				http.Error(w, "seeker can't seek", http.StatusInternalServerError)
   121  				return err
   122  			}
   123  		}
   124  		w.Header().Set("Content-Type", ctype)
   125  	} else if len(ctypes) > 0 {
   126  		ctype = ctypes[0]
   127  	}
   128  
   129  	injector, err := ci.Sniff(content, ctype)
   130  	if err != nil {
   131  		http.Error(w, err.Error(), http.StatusInternalServerError)
   132  		return err
   133  	}
   134  
   135  	size, err := sizeFunc()
   136  	if err != nil {
   137  		http.Error(w, err.Error(), http.StatusInternalServerError)
   138  		return err
   139  	}
   140  
   141  	if injector.Found() {
   142  		size = size + int64(injector.Extra())
   143  	}
   144  
   145  	if size >= 0 {
   146  		if w.Header().Get("Content-Encoding") == "" {
   147  			w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
   148  		}
   149  	}
   150  
   151  	w.WriteHeader(code)
   152  	if r.Method != "HEAD" {
   153  		_, err := injector.Copy(w)
   154  		if err != nil {
   155  			return err
   156  		}
   157  	}
   158  	return nil
   159  }
   160  
   161  // modtime is the modification time of the resource to be served, or IsZero().
   162  // return value is whether this request is now complete.
   163  func checkLastModified(w http.ResponseWriter, r *http.Request, modtime time.Time) bool {
   164  	if modtime.IsZero() {
   165  		return false
   166  	}
   167  
   168  	// The Date-Modified header truncates sub-second precision, so
   169  	// use mtime < t+1s instead of mtime <= t to check for unmodified.
   170  	if t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since")); err == nil && modtime.Before(t.Add(1*time.Second)) {
   171  		h := w.Header()
   172  		delete(h, "Content-Type")
   173  		delete(h, "Content-Length")
   174  		w.WriteHeader(http.StatusNotModified)
   175  		return true
   176  	}
   177  	w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
   178  	return false
   179  }
   180  
   181  // checkETag implements If-None-Match checks.
   182  // The ETag must have been previously set in the ResponseWriter's headers.
   183  //
   184  // The return value is whether this request is now considered done.
   185  func checkETag(w http.ResponseWriter, r *http.Request) (done bool) {
   186  	etag := rawHeaderGet(w.Header(), "Etag")
   187  	if inm := rawHeaderGet(r.Header, "If-None-Match"); inm != "" {
   188  		// Must know ETag.
   189  		if etag == "" {
   190  			return false
   191  		}
   192  
   193  		// TODO(bradfitz): non-GET/HEAD requests require more work:
   194  		// sending a different status code on matches, and
   195  		// also can't use weak cache validators (those with a "W/
   196  		// prefix).  But most users of ServeContent will be using
   197  		// it on GET or HEAD, so only support those for now.
   198  		if r.Method != "GET" && r.Method != "HEAD" {
   199  			return false
   200  		}
   201  
   202  		// TODO(bradfitz): deal with comma-separated or multiple-valued
   203  		// list of If-None-match values.  For now just handle the common
   204  		// case of a single item.
   205  		if inm == etag || inm == "*" {
   206  			h := w.Header()
   207  			delete(h, "Content-Type")
   208  			delete(h, "Content-Length")
   209  			w.WriteHeader(http.StatusNotModified)
   210  			return true
   211  		}
   212  	}
   213  	return false
   214  }
   215  
   216  // localRedirect gives a Moved Permanently response.
   217  // It does not convert relative paths to absolute paths like Redirect does.
   218  func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
   219  	if q := r.URL.RawQuery; q != "" {
   220  		newPath += "?" + q
   221  	}
   222  	w.Header().Set("Location", newPath)
   223  	w.WriteHeader(http.StatusMovedPermanently)
   224  }
   225  
   226  // FileServer returns a handler that serves HTTP requests
   227  // with the contents of the file system rooted at root.
   228  //
   229  // To use the operating system's file system implementation,
   230  // use http.Dir:
   231  //
   232  //	http.Handle("/", &fileserver.FileServer{Root: http.Dir("/tmp")})
   233  type FileServer struct {
   234  	Version        string
   235  	Root           http.FileSystem
   236  	Inject         inject.CopyInject
   237  	Templates      *template.Template
   238  	NotFoundRoutes []routespec.RouteSpec
   239  	Prefix         string
   240  }
   241  
   242  func (fserver *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   243  	fserver.ServeHTTPContext(context.Background(), w, r)
   244  }
   245  
   246  // ServeHTTPContext is like ServeHTTP, but with added context
   247  func (fserver *FileServer) ServeHTTPContext(
   248  	ctx context.Context, w http.ResponseWriter, r *http.Request,
   249  ) {
   250  	logger := termlog.FromContext(ctx)
   251  	logger.SayAs("debug", "debug fileserver: serving with FileServer...")
   252  
   253  	upath := stripPrefix(fserver.Prefix, r.URL.Path)
   254  	if !strings.HasPrefix(upath, "/") {
   255  		upath = "/" + upath
   256  	}
   257  	fserver.serveFile(logger, w, r, path.Clean(upath), true)
   258  }
   259  
   260  // Given a path and a "not found" over-ride specification, return an array of
   261  // over-ride paths that should be considered for serving, in priority order. We
   262  // assume that path is a sub-path above a certain root, and we never return
   263  // paths that would fall outside this.
   264  //
   265  // We also sanity check file extensions to make sure that the expected file
   266  // type matches what we serve. This prevents an over-ride for *.html files from
   267  // serving up data when, say, a missing .png is requested.
   268  func notFoundSearchPaths(pth string, spec string) []string {
   269  	var ret []string
   270  	if strings.HasPrefix(spec, "/") {
   271  		ret = []string{path.Clean(spec)}
   272  	} else {
   273  		for {
   274  			pth = path.Dir(pth)
   275  			if pth == "/" {
   276  				ret = append(ret, path.Join(pth, spec))
   277  				break
   278  			}
   279  			ret = append(ret, path.Join(pth, spec))
   280  		}
   281  	}
   282  	return ret
   283  }
   284  
   285  // Get the media type for an extension, via a MIME lookup, defaulting to
   286  // "text/html".
   287  func _getType(ext string) string {
   288  	typ := mime.TypeByExtension(ext)
   289  	if typ == "" {
   290  		return "text/html"
   291  	}
   292  	smime, _, err := mime.ParseMediaType(typ)
   293  	if err != nil {
   294  		return "text/html"
   295  	}
   296  	return smime
   297  }
   298  
   299  // Checks whether the incoming request has the same expected type as an
   300  // over-ride specification.
   301  func matchTypes(spec string, req string) bool {
   302  	smime := _getType(path.Ext(spec))
   303  	rmime := _getType(path.Ext(req))
   304  	if smime == rmime {
   305  		return true
   306  	}
   307  	return false
   308  }
   309  
   310  func (fserver *FileServer) serve404(w http.ResponseWriter) error {
   311  	d := fourohfourData{
   312  		Version: fserver.Version,
   313  	}
   314  	err := fserver.Inject.ServeTemplate(
   315  		http.StatusNotFound,
   316  		w,
   317  		fserver.Templates.Lookup("404.html"),
   318  		&d,
   319  	)
   320  	if err != nil {
   321  		return err
   322  	}
   323  	return nil
   324  }
   325  
   326  func (fserver *FileServer) dirList(logger termlog.Logger, w http.ResponseWriter, name string, f http.File) {
   327  	w.Header().Set("Cache-Control", "no-store, must-revalidate")
   328  	files, err := f.Readdir(0)
   329  	if err != nil {
   330  		logger.Shout("Error reading directory for listing: %s", err)
   331  		return
   332  	}
   333  	sortedFiles := fileSlice(files)
   334  	sort.Sort(sortedFiles)
   335  	data := dirData{
   336  		Version: fserver.Version,
   337  		Name:    name,
   338  		Files:   sortedFiles,
   339  	}
   340  	err = fserver.Inject.ServeTemplate(
   341  		http.StatusOK,
   342  		w,
   343  		fserver.Templates.Lookup("dirlist.html"),
   344  		data,
   345  	)
   346  	if err != nil {
   347  		logger.Shout("Failed to generate dir listing: %s", err)
   348  	}
   349  }
   350  
   351  func (fserver *FileServer) notFound(
   352  	logger termlog.Logger,
   353  	w http.ResponseWriter,
   354  	r *http.Request,
   355  	name string,
   356  	dir *http.File,
   357  ) (err error) {
   358  	sm := http.NewServeMux()
   359  	seen := make(map[string]bool)
   360  	for _, nfr := range fserver.NotFoundRoutes {
   361  		seen[nfr.MuxMatch()] = true
   362  		sm.HandleFunc(
   363  			nfr.MuxMatch(),
   364  			func(nfr routespec.RouteSpec) func(w http.ResponseWriter, r *http.Request) {
   365  				return func(w http.ResponseWriter, r *http.Request) {
   366  					if matchTypes(nfr.Value, r.URL.Path) {
   367  						for _, pth := range notFoundSearchPaths(name, nfr.Value) {
   368  							next, err := fserver.serveNotFoundFile(w, r, pth)
   369  							if err != nil {
   370  								logger.Shout("Unable to serve not-found override: %s", err)
   371  							}
   372  							if !next {
   373  								return
   374  							}
   375  						}
   376  					}
   377  					err = fserver.serve404(w)
   378  					if err != nil {
   379  						logger.Shout("Internal error: %s", err)
   380  					}
   381  				}
   382  			}(nfr),
   383  		)
   384  	}
   385  	if _, exists := seen["/"]; !exists {
   386  		sm.HandleFunc(
   387  			"/",
   388  			func(response http.ResponseWriter, request *http.Request) {
   389  				if dir != nil {
   390  					d, err := (*dir).Stat()
   391  					if err != nil {
   392  						logger.Shout("Internal error: %s", err)
   393  						return
   394  					}
   395  					if checkLastModified(response, request, d.ModTime()) {
   396  						return
   397  					}
   398  					fserver.dirList(logger, response, name, *dir)
   399  					return
   400  				}
   401  				err = fserver.serve404(w)
   402  				if err != nil {
   403  					logger.Shout("Internal error: %s", err)
   404  				}
   405  			},
   406  		)
   407  	}
   408  	handle, _ := sm.Handler(r)
   409  	handle.ServeHTTP(w, r)
   410  	return err
   411  }
   412  
   413  // If the next return value is true, the caller should proceed to the next
   414  // over-ride path if there is one. If the err return value is non-nil, serving
   415  // should stop.
   416  func (fserver *FileServer) serveNotFoundFile(
   417  	w http.ResponseWriter,
   418  	r *http.Request,
   419  	name string,
   420  ) (next bool, err error) {
   421  	f, err := fserver.Root.Open(name)
   422  	if err != nil {
   423  		return true, nil
   424  	}
   425  	defer func() { _ = f.Close() }()
   426  
   427  	d, err := f.Stat()
   428  	if err != nil || d.IsDir() {
   429  		return true, nil
   430  	}
   431  
   432  	// serverContent will check modification time
   433  	sizeFunc := func() (int64, error) { return d.Size(), nil }
   434  	err = serveContent(fserver.Inject, w, r, d.Name(), d.ModTime(), sizeFunc, f)
   435  	if err != nil {
   436  		return false, fmt.Errorf("Error serving file: %s", err)
   437  	}
   438  	return false, nil
   439  }
   440  
   441  // name is '/'-separated, not filepath.Separator.
   442  func (fserver *FileServer) serveFile(
   443  	logger termlog.Logger,
   444  	w http.ResponseWriter,
   445  	r *http.Request,
   446  	name string,
   447  	redirect bool,
   448  ) {
   449  	const indexPage = "/index.html"
   450  
   451  	// redirect .../index.html to .../
   452  	// can't use Redirect() because that would make the path absolute,
   453  	// which would be a problem running under StripPrefix
   454  	if strings.HasSuffix(r.URL.Path, indexPage) {
   455  		logger.SayAs(
   456  			"debug", "debug fileserver: redirecting %s -> ./", indexPage,
   457  		)
   458  		localRedirect(w, r, "./")
   459  		return
   460  	}
   461  
   462  	f, err := fserver.Root.Open(name)
   463  	if err != nil {
   464  		logger.WarnAs("debug", "debug fileserver: %s", err)
   465  		if err := fserver.notFound(logger, w, r, name, nil); err != nil {
   466  			logger.Shout("Internal error: %s", err)
   467  		}
   468  		return
   469  	}
   470  	defer func() { _ = f.Close() }()
   471  
   472  	d, err1 := f.Stat()
   473  	if err1 != nil {
   474  		logger.WarnAs("debug", "debug fileserver: %s", err)
   475  		if err := fserver.notFound(logger, w, r, name, nil); err != nil {
   476  			logger.Shout("Internal error: %s", err)
   477  		}
   478  		return
   479  	}
   480  
   481  	if redirect {
   482  		// redirect to canonical path: / at end of directory url
   483  		url := r.URL.Path
   484  		if !strings.HasPrefix(url, "/") {
   485  			url = "/" + url
   486  		}
   487  		if d.IsDir() {
   488  			if url[len(url)-1] != '/' {
   489  				localRedirect(w, r, path.Base(url)+"/")
   490  				return
   491  			}
   492  		} else if url[len(url)-1] == '/' {
   493  			localRedirect(w, r, "../"+path.Base(url))
   494  			return
   495  		}
   496  	}
   497  
   498  	// use contents of index.html for directory, if present
   499  	if d.IsDir() {
   500  		index := name + indexPage
   501  		ff, err := fserver.Root.Open(index)
   502  		if err == nil {
   503  			defer func() { _ = ff.Close() }()
   504  			dd, err := ff.Stat()
   505  			if err == nil {
   506  				name = index
   507  				d = dd
   508  				f = ff
   509  			}
   510  		}
   511  	}
   512  
   513  	// Still a directory? (we didn't find an index.html file)
   514  	if d.IsDir() {
   515  		if err := fserver.notFound(logger, w, r, name, &f); err != nil {
   516  			logger.Shout("Internal error: %s", err)
   517  		}
   518  		return
   519  	}
   520  
   521  	// serverContent will check modification time
   522  	sizeFunc := func() (int64, error) { return d.Size(), nil }
   523  	err = serveContent(fserver.Inject, w, r, d.Name(), d.ModTime(), sizeFunc, f)
   524  	if err != nil {
   525  		logger.Warn("Error serving file: %s", err)
   526  	}
   527  }