07 Apr 2017

How to serve static files with custom NotFound handler

article lead

Serving static files is necessary for http server. Usually when file is not present, we send simple 404 page. It's different for Angular. Here we have to send our index.html and let Router take the job. With vanilla http.FileServer it's impossible to have custom handler when file does not exist. Not anymore...

Simple static server looks like this:

package main

import (
  "net/http"
  "time"
  "log"
)

func main() {
  mux := http.NewServeMux()
  mux.Handle("/", http.FileServer(http.Dir("public")))
  server := &http.Server{
    Addr: ":80",
    Handler: mux,
    ReadTimeout: 10 * time.Second,
    WriteTimeout: 10 * time.Second,
    MaxHeaderBytes: 1 << 20,
  }
  log.Fatal(server.ListenAndServe())
}

When we request not existing file we get 404 page not found message. That's not what we want for SPA. We need to send our index.html.

First add customFileServer struct and implement http.Handler interface for it. Also add convenient construction function.

type customFileServer struct {
  root http.Dir
  NotFoundHandler func(http.ResponseWriter, *http.Request)
}
func (fs *customFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {}
func CustomFileServer(root http.Dir, NotFoundHandler http.HandlerFunc) http.Handler {
  return &customFileServer{root: root, NotFoundHandler: NotFoundHandler}
}

To operate on files, we need to import os package. Add also strings package to check prefix of url, path and path/filepath to manipulate paths.

import (
  //...
  "os"
  "strings"
  "path"[label](file://n/)
  "path/filepath"
)

Then implement our ServeHTTP method

func (fs *customFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  //if empty, set current directory
  dir := string(fs.root)
  if dir == "" {
    dir = "."
  }

  //add prefix and clean
  upath := r.URL.Path
  if !strings.HasPrefix(upath, "/") {
    upath = "/" + upath
    r.URL.Path = upath
  }
  upath = path.Clean(upath)

  //path to file
  name := path.Join(dir, filepath.FromSlash(upath))

  //check if file exists
  f, err := os.Open(name)
  if err != nil {
    if os.IsNotExist(err) {
      fs.NotFoundHandler(w, r);
      return;
    }
  }
  defer f.Close()

  http.ServeFile(w, r, name)
}

If our root directory is empty, we set current directory as root. Then we add '/' to requested url if necessary. Our path to file is path.Join'ed from root directory and url. Now we check if it exists. If not, use our custom NotFoundHandler to serve content. Otherwise let golang serve file using http.ServeFile. Note that we use defer f.Close() to close file when function ends.

That would be it, but our code is vulnerable to path traversal attack. This program may open files outside of root when we add '../' to path. To fix that, we will use same approach as http package does. Implement these two helper functions.

func containsDotDot(v string) bool {
  if !strings.Contains(v, "..") {
    return false
  }
  for _, ent := range strings.FieldsFunc(v, isSlashRune) {
    if ent == ".." {
      return true
    }
  }
  return false
}

func isSlashRune(r rune) bool { return r == '/' || r == '\\' }

And use it at the beginning of ServeHTTP.

func (fs *customFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  if containsDotDot(r.URL.Path) {
    http.Error(w, "URL should not contain '/../' parts", http.StatusBadRequest)
    return
  }
  //...
}

We are ready to go. Change main function to this.

func main() {
  serveIndexHtml := func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "public/index.html")
  }
  mux := http.NewServeMux()
  mux.Handle("/", CustomFileServer(http.Dir("public"), serveIndexHtml))

  server := &http.Server{
    Addr: ":80",
    Handler: mux,
    ReadTimeout: 10 * time.Second,
    WriteTimeout: 10 * time.Second,
    MaxHeaderBytes: 1 << 20,
  }
  log.Fatal(server.ListenAndServe())
}

You can now serve static files. When file does not exist, you can use your custom handler. We fallback to index.html in this example.