notary/internal/noteweb: basic notary web frontend

This is part of a design sketch for a Go module notary.
Eventually the code will live outside golang.org/x/exp.

Everything here is subject to change! Don't depend on it!

This package defines the basic web handlers for
the notary URL endpoints.

Change-Id: I66b8acf99ae08a3959cb8e0192651c01920d1ae4
Reviewed-on: https://go-review.googlesource.com/c/exp/+/162898
Run-TryBot: Russ Cox <rsc@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Filippo Valsorda <filippo@golang.org>
diff --git a/notary/internal/noteweb/web.go b/notary/internal/noteweb/web.go
new file mode 100644
index 0000000..ada39d6
--- /dev/null
+++ b/notary/internal/noteweb/web.go
@@ -0,0 +1,169 @@
+// Copyright 2019 The Go 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 noteweb serves the notary web endpoints from a notary database.
+package noteweb
+
+import (
+	"context"
+	"net/http"
+	"os"
+	"regexp"
+	"strings"
+
+	"golang.org/x/exp/notary/internal/tlog"
+)
+
+// Server is a connection to a notary server.
+type Server interface {
+	// NewContext returns the context to use for the request r.
+	NewContext(r *http.Request) (context.Context, error)
+
+	// Signed returns the signed hash of the latest tree.
+	Signed(ctx context.Context) ([]byte, error)
+
+	// ReadRecords returns the content for the n records id through id+n-1.
+	ReadRecords(ctx context.Context, id, n int64) ([][]byte, error)
+
+	// FindKey looks up a record by its associated key ("module@version"),
+	// returning the record ID.
+	FindKey(ctx context.Context, key string) (int64, error)
+
+	// ReadTileData reads the content of tile t.
+	// It is only invoked for hash tiles (t.L ≥ 0).
+	ReadTileData(ctx context.Context, t tlog.Tile) ([]byte, error)
+}
+
+// Handler is the notary endpoint handler,
+// which should be used for the paths listed in Paths.
+// The client is responsible for initializing Server.
+type Handler struct {
+	Server Server
+}
+
+// Paths are the URL paths for which Handler should be invoked.
+//
+// Typically a client will do:
+//
+//	handler := &noteweb.Handler{Server: srv}
+//	for _, path := range noteweb.Paths {
+//		http.HandleFunc(path, handler)
+//	}
+//
+var Paths = []string{
+	"/lookup/",
+	"/latest",
+	"/tile/",
+}
+
+var modVerRE = regexp.MustCompile(`^[^@]+@v[0-9]+\.[0-9]+\.[0-9]+(-[^@]*)?$`)
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, err := h.Server.NewContext(r)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+
+	switch {
+	default:
+		http.NotFound(w, r)
+
+	case strings.HasPrefix(r.URL.Path, "/lookup/"):
+		mod := strings.TrimPrefix(r.URL.Path, "/lookup/")
+		if !modVerRE.MatchString(mod) {
+			http.Error(w, "invalid module@version syntax", http.StatusBadRequest)
+			return
+		}
+		id, err := h.Server.FindKey(ctx, mod)
+		if err != nil {
+			reportError(w, r, err)
+			return
+		}
+		records, err := h.Server.ReadRecords(ctx, id, 1)
+		if err != nil {
+			// This should never happen - the lookup says the record exists.
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		if len(records) != 1 {
+			http.Error(w, "invalid record count returned by ReadRecords", http.StatusInternalServerError)
+			return
+		}
+		msg, err := tlog.FormatRecord(id, records[0])
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		signed, err := h.Server.Signed(ctx)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
+		w.Write(msg)
+		w.Write(signed)
+
+	case r.URL.Path == "/latest":
+		data, err := h.Server.Signed(ctx)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
+		w.Write(data)
+
+	case strings.HasPrefix(r.URL.Path, "/tile/"):
+		t, err := tlog.ParseTilePath(r.URL.Path[1:])
+		if err != nil {
+			http.Error(w, "invalid tile syntax", http.StatusBadRequest)
+			return
+		}
+		if t.L == -1 {
+			// Record data.
+			start := t.N << uint(t.H)
+			records, err := h.Server.ReadRecords(ctx, start, int64(t.W))
+			if err != nil {
+				reportError(w, r, err)
+				return
+			}
+			if len(records) != t.W {
+				http.Error(w, "invalid record count returned by ReadRecords", http.StatusInternalServerError)
+				return
+			}
+			var data []byte
+			for i, text := range records {
+				msg, err := tlog.FormatRecord(start+int64(i), text)
+				if err != nil {
+					http.Error(w, err.Error(), http.StatusInternalServerError)
+				}
+				data = append(data, msg...)
+			}
+			w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
+			w.Write(data)
+			return
+		}
+
+		data, err := h.Server.ReadTileData(ctx, t)
+		if err != nil {
+			reportError(w, r, err)
+			return
+		}
+		w.Header().Set("Content-Type", "application/octet-stream")
+		w.Write(data)
+	}
+}
+
+// reportError reports err to w.
+// If it's a not-found, the reported error is 404.
+// Otherwise it is an internal server error.
+// The caller must only call reportError in contexts where
+// a not-found err should be reported as 404.
+func reportError(w http.ResponseWriter, r *http.Request, err error) {
+	if os.IsNotExist(err) {
+		http.Error(w, err.Error(), http.StatusNotFound)
+		return
+	}
+	http.Error(w, err.Error(), http.StatusInternalServerError)
+}