cmd/browser: bundle static files in static.go for easier distribution

When we ship the browser executable we don't want to require the user
place the static files (HTML, JS, etc) in a well-known location.
This change bundles those files into the browser executable itself,
making it self-contained.

The generated static.go is excluded from being committed to the repo, as
it should be a pre-distribution step. Those who build from source
already have the files in a well-known location (the package path inside
a Go workspace) so there's no need to check in the generated file.

Change-Id: I3fbf766543370832ccdc0b98b5995dd1a2aae058
Reviewed-on: https://upspin-review.googlesource.com/13060
Reviewed-by: Rob Pike <r@golang.org>
diff --git a/cmd/browser/main.go b/cmd/browser/main.go
index b2e3cfe..ea196b9 100644
--- a/cmd/browser/main.go
+++ b/cmd/browser/main.go
@@ -20,7 +20,6 @@
 	"encoding/json"
 	"flag"
 	"fmt"
-	"go/build"
 	"net"
 	"net/http"
 	"os"
@@ -29,6 +28,9 @@
 	"runtime"
 	"strings"
 	"sync"
+	"time"
+
+	"exp.upspin.io/cmd/browser/static"
 
 	"golang.org/x/net/xsrftoken"
 
@@ -79,9 +81,6 @@
 	// key to prevent request forgery; static for server's lifetime.
 	key string
 
-	// Handler for serving static content (HTML, JS, etc).
-	static http.Handler
-
 	mu  sync.Mutex
 	cfg upspin.Config // Non-nil if signup flow has been completed.
 	cli upspin.Client
@@ -93,14 +92,8 @@
 		return nil, err
 	}
 
-	pkg, err := build.Default.Import("exp.upspin.io/cmd/browser/static", "", build.FindOnly)
-	if err != nil {
-		return nil, fmt.Errorf("could not find static web content: %v", err)
-	}
-
 	return &server{
-		key:    key,
-		static: http.FileServer(http.Dir(pkg.Dir)),
+		key: key,
 	}, nil
 }
 
@@ -120,7 +113,24 @@
 		s.serveContent(w, r)
 		return
 	}
-	s.static.ServeHTTP(w, r)
+	s.serveStatic(w, r)
+}
+
+func (s *server) serveStatic(w http.ResponseWriter, r *http.Request) {
+	p := r.URL.Path[1:]
+	if p == "" {
+		p = "index.html"
+	}
+	b, err := static.File(p)
+	if errors.Match(errors.E(errors.NotExist), err) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.ServeContent(w, r, path.Base(p), time.Now(), strings.NewReader(b))
 }
 
 func (s *server) serveContent(w http.ResponseWriter, r *http.Request) {
diff --git a/cmd/browser/static/.gitignore b/cmd/browser/static/.gitignore
new file mode 100644
index 0000000..63680a0
--- /dev/null
+++ b/cmd/browser/static/.gitignore
@@ -0,0 +1,4 @@
+# Don't commit static.go; it should be generated before building a release
+# version of the browser binary. Only files generated by the build process
+# should be included here.
+static.go
diff --git a/cmd/browser/static/gen.go b/cmd/browser/static/gen.go
new file mode 100644
index 0000000..585ced2
--- /dev/null
+++ b/cmd/browser/static/gen.go
@@ -0,0 +1,58 @@
+// Copyright 2017 The Upspin Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package static provides access to static assets, such as HTML, CSS,
+// JavaScript, and image files.
+package static
+
+import (
+	"go/build"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"sync"
+
+	"upspin.io/errors"
+)
+
+//go:generate go run makestatic.go
+
+var files map[string]string
+
+var static struct {
+	once sync.Once
+	dir  string
+}
+
+// File returns the file rooted at "exp.upspin.io/cmd/browser/static" either
+// from an in-memory map or, if no map was generated, the contents of the file
+// from disk.
+func File(name string) (string, error) {
+	if files != nil {
+		b, ok := files[name]
+		if !ok {
+			return "", errors.E(errors.NotExist, errors.Str("file not found"))
+		}
+		return b, nil
+
+	}
+	static.once.Do(func() {
+		pkg, _ := build.Default.Import("exp.upspin.io/cmd/browser/static", "", build.FindOnly)
+		if pkg == nil {
+			return
+		}
+		static.dir = pkg.Dir
+	})
+	if static.dir == "" {
+		return "", errors.E(errors.NotExist, errors.Str("could not find static assets"))
+	}
+	b, err := ioutil.ReadFile(filepath.Join(static.dir, name))
+	if err != nil {
+		if os.IsNotExist(err) {
+			return "", errors.E(errors.NotExist, err)
+		}
+		return "", err
+	}
+	return string(b), nil
+}
diff --git a/cmd/browser/static/makestatic.go b/cmd/browser/static/makestatic.go
new file mode 100644
index 0000000..dd865c4
--- /dev/null
+++ b/cmd/browser/static/makestatic.go
@@ -0,0 +1,97 @@
+// Copyright 2013 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the https://golang.org/LICENSE file.
+
+// +build ignore
+
+// This file is adapted from golang.org/x/tools/godoc/static/makestatic.go.
+
+// Command makestatic reads a set of files and writes a Go source file to
+// "static.go" that declares a map of string constants containing contents of
+// the input files. It is intended to be invoked via "go generate".
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"go/format"
+	"io/ioutil"
+	"os"
+	"time"
+	"unicode/utf8"
+)
+
+var files = []string{
+	"augie.png",
+	"index.html",
+	"script.js",
+	"third_party/bootstrap/css/bootstrap-theme.min.css",
+	"third_party/bootstrap/css/bootstrap-theme.min.css.map",
+	"third_party/bootstrap/css/bootstrap.min.css",
+	"third_party/bootstrap/css/bootstrap.min.css.map",
+	"third_party/bootstrap/fonts/glyphicons-halflings-regular.eot",
+	"third_party/bootstrap/fonts/glyphicons-halflings-regular.svg",
+	"third_party/bootstrap/fonts/glyphicons-halflings-regular.ttf",
+	"third_party/bootstrap/fonts/glyphicons-halflings-regular.woff",
+	"third_party/bootstrap/fonts/glyphicons-halflings-regular.woff2",
+	"third_party/bootstrap/js/bootstrap.min.js",
+	"third_party/jquery/jquery.min.js",
+	"third_party/ladda/ladda-themeless.min.css",
+	"third_party/ladda/ladda.min.js",
+	"third_party/ladda/spin.min.js",
+}
+
+func main() {
+	if err := makestatic(); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+func makestatic() error {
+	f, err := os.Create("static.go")
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	buf := new(bytes.Buffer)
+	fmt.Fprintf(buf, "%v\n\n%v\n\npackage static\n\n", license, warning)
+	fmt.Fprintf(buf, "func init() { files = map[string]string{\n")
+	for _, fn := range files {
+		b, err := ioutil.ReadFile(fn)
+		if err != nil {
+			return err
+		}
+		fmt.Fprintf(buf, "\t%q: ", fn)
+		if utf8.Valid(b) {
+			fmt.Fprintf(buf, "`%s`", sanitize(b))
+		} else {
+			fmt.Fprintf(buf, "%q", b)
+		}
+		fmt.Fprintln(buf, ",\n")
+	}
+	fmt.Fprintln(buf, "}}")
+	fmtbuf, err := format.Source(buf.Bytes())
+	if err != nil {
+		return err
+	}
+	return ioutil.WriteFile("static.go", fmtbuf, 0666)
+}
+
+// sanitize prepares a valid UTF-8 string as a raw string constant.
+func sanitize(b []byte) []byte {
+	// Replace ` with `+"`"+`
+	b = bytes.Replace(b, []byte("`"), []byte("`+\"`\"+`"), -1)
+
+	// Replace BOM with `+"\xEF\xBB\xBF"+`
+	// (A BOM is valid UTF-8 but not permitted in Go source files.
+	// I wouldn't bother handling this, but for some insane reason
+	// jquery.js has a BOM somewhere in the middle.)
+	return bytes.Replace(b, []byte("\xEF\xBB\xBF"), []byte("`+\"\\xEF\\xBB\\xBF\"+`"), -1)
+}
+
+const warning = `// Code generated by "makestatic"; DO NOT EDIT.`
+
+var license = fmt.Sprintf(`// Copyright %d The Upspin Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.`, time.Now().UTC().Year())