Rework how we bypass browser caches of the scouting app

This patch adds a new way to access files on the webserver. Users can
now request files by their sha256 checksum. The URL pattern follows
`/sha256/<checksum>/<path>`. The `<path>` portion doesn't actually
matter, but it helps with readability.

This patch migrates the main bundle and the pictures to use the new
checksum-addressable scheme.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: I76eaa9b0f69af98e48e8e73e32c82ce2916fbe41
diff --git a/scouting/webserver/static/BUILD b/scouting/webserver/static/BUILD
index 3166853..0cf22f3 100644
--- a/scouting/webserver/static/BUILD
+++ b/scouting/webserver/static/BUILD
@@ -13,6 +13,7 @@
     name = "static_test",
     srcs = ["static_test.go"],
     data = [
+        "test_pages/index.html",
         "test_pages/page.txt",
         "test_pages/root.txt",
     ],
diff --git a/scouting/webserver/static/static.go b/scouting/webserver/static/static.go
index 92d8086..4c46fe7 100644
--- a/scouting/webserver/static/static.go
+++ b/scouting/webserver/static/static.go
@@ -2,7 +2,15 @@
 
 // A year agnostic way to serve static http files.
 import (
+	"crypto/sha256"
+	"errors"
+	"fmt"
+	"io"
+	"log"
 	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
 	"time"
 
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
@@ -20,10 +28,98 @@
 	"X-Accel-Expires": "0",
 }
 
-func NoCache(h http.Handler) http.Handler {
+func MaybeNoCache(h http.Handler) http.Handler {
 	fn := func(w http.ResponseWriter, r *http.Request) {
-		for k, v := range noCacheHeaders {
-			w.Header().Set(k, v)
+		// We force the browser not to cache index.html so that
+		// browsers will notice when the bundle gets updated.
+		if r.URL.Path == "/" || r.URL.Path == "/index.html" {
+			for k, v := range noCacheHeaders {
+				w.Header().Set(k, v)
+			}
+		}
+
+		h.ServeHTTP(w, r)
+	}
+
+	return http.HandlerFunc(fn)
+}
+
+// Computes the sha256 of the specified file.
+func computeSha256(path string) (string, error) {
+	file, err := os.Open(path)
+	if err != nil {
+		return "", errors.New(fmt.Sprint("Failed to open ", path, ": ", err))
+	}
+	defer file.Close()
+
+	hash := sha256.New()
+	if _, err := io.Copy(hash, file); err != nil {
+		return "", errors.New(fmt.Sprint("Failed to compute sha256 of ", path, ": ", err))
+	}
+	return fmt.Sprintf("%x", hash.Sum(nil)), nil
+}
+
+// Finds the checksums for all the files in the specified directory. This is a
+// best effort only. If for some reason we fail to compute the checksum of
+// something, we just move on.
+func findAllFileShas(directory string) map[string]string {
+	shaSums := make(map[string]string)
+
+	// Find the checksums for all the files.
+	err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			log.Println("Walk() didn't want to deal with ", path, ":", err)
+			return nil
+		}
+		if info.IsDir() {
+			// We only care about computing checksums of files.
+			// Ignore directories.
+			return nil
+		}
+		hash, err := computeSha256(path)
+		if err != nil {
+			log.Println(err)
+			return nil
+		}
+		shaSums[hash] = "/" + strings.TrimPrefix(path, directory)
+		return nil
+	})
+	if err != nil {
+		log.Fatal("Got unexpected error from Walk(): ", err)
+	}
+
+	return shaSums
+}
+
+func HandleShaUrl(directory string, h http.Handler) http.Handler {
+	shaSums := findAllFileShas(directory)
+
+	fn := func(w http.ResponseWriter, r *http.Request) {
+		// We expect the path portion to look like this:
+		// /sha256/<checksum>/path...
+		// Splitting on / means we end up with this list:
+		// [0] ""
+		// [1] "sha256"
+		// [2] "<checksum>"
+		// [3-] path...
+		parts := strings.Split(r.URL.Path, "/")
+		if len(parts) < 4 {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		if parts[0] != "" || parts[1] != "sha256" {
+			// Something is fundamentally wrong. We told the
+			// framework to only give is /sha256/ requests.
+			log.Fatal("This handler should not be called for " + r.URL.Path)
+		}
+		hash := parts[2]
+		if path, ok := shaSums[hash]; ok {
+			// We found a file with this checksum. Serve that file.
+			r.URL.Path = path
+		} else {
+			// No file with this checksum found.
+			w.WriteHeader(http.StatusNotFound)
+			return
 		}
 
 		h.ServeHTTP(w, r)
@@ -35,8 +131,8 @@
 // Serve pages in the specified directory.
 func ServePages(scoutingServer server.ScoutingServer, directory string) {
 	// Serve the / endpoint given a folder of pages.
-	scoutingServer.Handle("/", NoCache(http.FileServer(http.Dir(directory))))
-	// Make an exception for pictures. We don't want the pictures to be
-	// pulled every time the page is refreshed.
-	scoutingServer.Handle("/pictures/", http.FileServer(http.Dir(directory)))
+	scoutingServer.Handle("/", MaybeNoCache(http.FileServer(http.Dir(directory))))
+
+	// Also serve files in a checksum-addressable manner.
+	scoutingServer.Handle("/sha256/", HandleShaUrl(directory, http.FileServer(http.Dir(directory))))
 }
diff --git a/scouting/webserver/static/static_test.go b/scouting/webserver/static/static_test.go
index 3524c05..09ed940 100644
--- a/scouting/webserver/static/static_test.go
+++ b/scouting/webserver/static/static_test.go
@@ -9,6 +9,12 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
 )
 
+func expectEqual(t *testing.T, actual string, expected string) {
+	if actual != expected {
+		t.Error("Expected ", actual, " to equal ", expected)
+	}
+}
+
 func TestServing(t *testing.T) {
 	cases := []struct {
 		// The path to request from the server.
@@ -17,6 +23,7 @@
 		// specified path.
 		expectedData string
 	}{
+		{"/", "<h1>This is the index</h1>\n"},
 		{"/root.txt", "Hello, this is the root page!"},
 		{"/page.txt", "Hello from a page!"},
 	}
@@ -24,37 +31,67 @@
 	scoutingServer := server.NewScoutingServer()
 	ServePages(scoutingServer, "test_pages")
 	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
 
 	// Go through all the test cases, and run them against the running webserver.
 	for _, c := range cases {
 		dataReceived := getData(c.path, t)
-		if dataReceived != c.expectedData {
-			t.Errorf("Got %q, but expected %q", dataReceived, c.expectedData)
-		}
+		expectEqual(t, dataReceived, c.expectedData)
 	}
-
-	scoutingServer.Stop()
 }
 
-func TestCache(t *testing.T) {
+// Makes sure that requesting / sets the proper headers so it doesn't get
+// cached.
+func TestDisallowedCache(t *testing.T) {
 	scoutingServer := server.NewScoutingServer()
 	ServePages(scoutingServer, "test_pages")
 	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	resp, err := http.Get("http://localhost:8080/")
+	if err != nil {
+		t.Fatal("Failed to get data ", err)
+	}
+	expectEqual(t, resp.Header.Get("Expires"), "Thu, 01 Jan 1970 00:00:00 UTC")
+	expectEqual(t, resp.Header.Get("Cache-Control"), "no-cache, private, max-age=0")
+	expectEqual(t, resp.Header.Get("Pragma"), "no-cache")
+	expectEqual(t, resp.Header.Get("X-Accel-Expires"), "0")
+}
+
+// Makes sure that requesting anything other than / doesn't set the "do not
+// cache" headers.
+func TestAllowedCache(t *testing.T) {
+	scoutingServer := server.NewScoutingServer()
+	ServePages(scoutingServer, "test_pages")
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
 
 	resp, err := http.Get("http://localhost:8080/root.txt")
 	if err != nil {
 		t.Fatalf("Failed to get data ", err)
 	}
-	compareString(resp.Header.Get("Expires"), "Thu, 01 Jan 1970 00:00:00 UTC", t)
-	compareString(resp.Header.Get("Cache-Control"), "no-cache, private, max-age=0", t)
-	compareString(resp.Header.Get("Pragma"), "no-cache", t)
-	compareString(resp.Header.Get("X-Accel-Expires"), "0", t)
+	expectEqual(t, resp.Header.Get("Expires"), "")
+	expectEqual(t, resp.Header.Get("Cache-Control"), "")
+	expectEqual(t, resp.Header.Get("Pragma"), "")
+	expectEqual(t, resp.Header.Get("X-Accel-Expires"), "")
 }
 
-func compareString(actual string, expected string, t *testing.T) {
-	if actual != expected {
-		t.Errorf("Expected ", actual, " to equal ", expected)
+func TestSha256(t *testing.T) {
+	scoutingServer := server.NewScoutingServer()
+	ServePages(scoutingServer, "test_pages")
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	// Validate a valid checksum.
+	dataReceived := getData("sha256/553b9b29647a112136986cf93c57b988d1f12dc43d3b774f14a24e58d272dbff/root.txt", t)
+	expectEqual(t, dataReceived, "Hello, this is the root page!")
+
+	// Make a request with an invalid checksum and make sure we get a 404.
+	resp, err := http.Get("http://localhost:8080/sha256/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/root.txt")
+	if err != nil {
+		t.Fatal("Failed to get data ", err)
 	}
+	expectEqual(t, resp.Status, "404 Not Found")
 }
 
 // Retrieves the data at the specified path. If an error occurs, the test case
@@ -66,7 +103,7 @@
 	}
 	// Error out if the return status is anything other than 200 OK.
 	if resp.Status != "200 OK" {
-		t.Fatalf("Received a status code other than 200")
+		t.Fatal("Received a status code other than 200:", resp.Status)
 	}
 	// Read the response body.
 	body, err := ioutil.ReadAll(resp.Body)
diff --git a/scouting/webserver/static/test_pages/index.html b/scouting/webserver/static/test_pages/index.html
new file mode 100644
index 0000000..d769db4
--- /dev/null
+++ b/scouting/webserver/static/test_pages/index.html
@@ -0,0 +1 @@
+<h1>This is the index</h1>