cmd/relui,internal/relui: serve from sub-paths

This change allows relui to correctly serve from a path, like
build.golang.org/releases. It adds a base-url flag which is used to
prefix all paths referenced in the application.

For golang/go#47401

Change-Id: Ib8f6fe429591ceabfaf0f419e5258a677b375ff8
Reviewed-on: https://go-review.googlesource.com/c/build/+/363975
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/internal/relui/templates/home.html b/internal/relui/templates/home.html
index cd8f73c..ebd946d 100644
--- a/internal/relui/templates/home.html
+++ b/internal/relui/templates/home.html
@@ -7,7 +7,7 @@
   <section class="Workflows">
     <div class="Workflows-header">
       <h2>Workflows</h2>
-      <a href="/workflows/new" class="Button">New</a>
+      <a href="{{baseLink "/workflows/new"}}" class="Button">New</a>
     </div>
     <ul class="WorkflowList">
       {{range $workflow := .Workflows}}
diff --git a/internal/relui/templates/layout.html b/internal/relui/templates/layout.html
index bf0a4aa..69f6ec1 100644
--- a/internal/relui/templates/layout.html
+++ b/internal/relui/templates/layout.html
@@ -7,7 +7,7 @@
 <html lang="en">
   <title>Go Releases</title>
   <meta name="viewport" content="width=device-width, initial-scale=1" />
-  <link rel="stylesheet" href="/static/styles.css" />
+  <link rel="stylesheet" href="{{baseLink "/static/styles.css"}}" />
   <body class="Site">
     <header class="Site-header">
       <div class="Header">
diff --git a/internal/relui/templates/new_workflow.html b/internal/relui/templates/new_workflow.html
index bdcf547..756cd5f 100644
--- a/internal/relui/templates/new_workflow.html
+++ b/internal/relui/templates/new_workflow.html
@@ -6,7 +6,7 @@
 {{define "content"}}
   <section class="NewWorkflow">
     <h2>New Go Release</h2>
-    <form action="/workflows/new" method="get">
+    <form action="{{baseLink "/workflows/new"}}" method="get">
       <label for="workflow.name">Workflow:</label>
       <select id="workflow.name" name="workflow.name" onchange="this.form.submit()">
         <option value="">Select Workflow</option>
diff --git a/internal/relui/web.go b/internal/relui/web.go
index 0536cd1..0565d89 100644
--- a/internal/relui/web.go
+++ b/internal/relui/web.go
@@ -15,6 +15,7 @@
 	"log"
 	"mime"
 	"net/http"
+	"net/url"
 	"path"
 
 	"github.com/google/uuid"
@@ -41,26 +42,31 @@
 	})
 }
 
-var (
-	homeTmpl        = template.Must(template.Must(layoutTmpl.Clone()).ParseFS(templates, "templates/home.html"))
-	layoutTmpl      = template.Must(template.ParseFS(templates, "templates/layout.html"))
-	newWorkflowTmpl = template.Must(template.Must(layoutTmpl.Clone()).ParseFS(templates, "templates/new_workflow.html"))
-)
-
 // Server implements the http handlers for relui.
 type Server struct {
-	db *pgxpool.Pool
-	m  *http.ServeMux
-	w  *Worker
+	db      *pgxpool.Pool
+	m       *http.ServeMux
+	w       *Worker
+	baseURL *url.URL
+
+	homeTmpl        *template.Template
+	newWorkflowTmpl *template.Template
 }
 
 // NewServer initializes a server with the provided connection pool.
-func NewServer(p *pgxpool.Pool, w *Worker) *Server {
+func NewServer(p *pgxpool.Pool, w *Worker, baseURL *url.URL) *Server {
 	s := &Server{
-		db: p,
-		m:  new(http.ServeMux),
-		w:  w,
+		db:      p,
+		m:       new(http.ServeMux),
+		w:       w,
+		baseURL: baseURL,
 	}
+	helpers := map[string]interface{}{
+		"baseLink": s.BaseLink,
+	}
+	layout := template.Must(template.New("layout.html").Funcs(helpers).ParseFS(templates, "templates/layout.html"))
+	s.homeTmpl = template.Must(template.Must(layout.Clone()).Funcs(helpers).ParseFS(templates, "templates/home.html"))
+	s.newWorkflowTmpl = template.Must(template.Must(layout.Clone()).Funcs(helpers).ParseFS(templates, "templates/new_workflow.html"))
 	s.m.Handle("/workflows/create", http.HandlerFunc(s.createWorkflowHandler))
 	s.m.Handle("/workflows/new", http.HandlerFunc(s.newWorkflowHandler))
 	s.m.Handle("/", fileServerHandler(static, http.HandlerFunc(s.homeHandler)))
@@ -68,13 +74,35 @@
 }
 
 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	s.m.ServeHTTP(w, r)
+	if s.baseURL == nil || s.baseURL.Path == "/" {
+		s.m.ServeHTTP(w, r)
+		return
+	}
+	http.StripPrefix(s.baseURL.Path, s.m)
 }
 
 func (s *Server) Serve(port string) error {
 	return http.ListenAndServe(":"+port, s.m)
 }
 
+func (s *Server) BaseLink(target string) string {
+	if s.baseURL == nil {
+		return target
+	}
+	u, err := url.Parse(target)
+	if err != nil {
+		log.Printf("BaseLink: url.Parse(%q) = %v, %v", target, u, err)
+		return target
+	}
+	if u.IsAbs() {
+		return u.String()
+	}
+	u.Scheme = s.baseURL.Scheme
+	u.Host = s.baseURL.Host
+	u.Path = path.Join(s.baseURL.Path, u.Path)
+	return u.String()
+}
+
 type homeResponse struct {
 	Workflows     []db.Workflow
 	WorkflowTasks map[uuid.UUID][]db.Task
@@ -104,7 +132,7 @@
 		return
 	}
 	out := bytes.Buffer{}
-	if err := homeTmpl.Execute(&out, resp); err != nil {
+	if err := s.homeTmpl.Execute(&out, resp); err != nil {
 		log.Printf("homeHandler: %v", err)
 		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
@@ -156,7 +184,7 @@
 		Definitions: Definitions(),
 		Name:        r.FormValue("workflow.name"),
 	}
-	if err := newWorkflowTmpl.Execute(&out, resp); err != nil {
+	if err := s.newWorkflowTmpl.Execute(&out, resp); err != nil {
 		log.Printf("newWorkflowHandler: %v", err)
 		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
diff --git a/internal/relui/web_test.go b/internal/relui/web_test.go
index a968410..52dfe92 100644
--- a/internal/relui/web_test.go
+++ b/internal/relui/web_test.go
@@ -116,7 +116,7 @@
 	req := httptest.NewRequest(http.MethodGet, "/", nil)
 	w := httptest.NewRecorder()
 
-	s := NewServer(p, NewWorker(p, &PGListener{p}))
+	s := NewServer(p, NewWorker(p, &PGListener{p}), nil)
 	s.homeHandler(w, req)
 	resp := w.Result()
 
@@ -152,7 +152,7 @@
 			req := httptest.NewRequest(http.MethodGet, u.String(), nil)
 			w := httptest.NewRecorder()
 
-			s := &Server{}
+			s := NewServer(nil, nil, nil)
 			s.newWorkflowHandler(w, req)
 			resp := w.Result()
 
@@ -219,7 +219,7 @@
 			rec := httptest.NewRecorder()
 			q := db.New(p)
 
-			s := NewServer(p, NewWorker(p, &PGListener{p}))
+			s := NewServer(p, NewWorker(p, &PGListener{p}), nil)
 			s.createWorkflowHandler(rec, req)
 			resp := rec.Result()
 
@@ -368,3 +368,55 @@
 func nullString(val string) sql.NullString {
 	return sql.NullString{String: val, Valid: true}
 }
+
+func TestServerBaseLink(t *testing.T) {
+	cases := []struct {
+		desc    string
+		baseURL string
+		target  string
+		want    string
+	}{
+		{
+			desc:   "no baseURL, relative",
+			target: "/workflows",
+			want:   "/workflows",
+		},
+		{
+			desc:   "no baseURL, absolute",
+			target: "https://example.test/something",
+			want:   "https://example.test/something",
+		},
+		{
+			desc:    "absolute baseURL, relative",
+			baseURL: "https://example.test/releases",
+			target:  "/workflows",
+			want:    "https://example.test/releases/workflows",
+		},
+		{
+			desc:    "relative baseURL, relative",
+			baseURL: "/releases",
+			target:  "/workflows",
+			want:    "/releases/workflows",
+		},
+		{
+			desc:    "absolute baseURL, absolute",
+			baseURL: "https://example.test/releases",
+			target:  "https://example.test/something",
+			want:    "https://example.test/something",
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			base, err := url.Parse(c.baseURL)
+			if err != nil {
+				t.Fatalf("url.Parse(%q) = %v, %v, wanted no error", c.baseURL, base, err)
+			}
+			s := NewServer(nil, nil, base)
+
+			got := s.BaseLink(c.target)
+			if got != c.want {
+				t.Errorf("s.BaseLink(%q) = %q, wanted %q", c.target, got, c.want)
+			}
+		})
+	}
+}