internal/worker: initial code for server

Set up a basic HTML server for the worker.

Change-Id: I4861b887eb5e9dca9e74eacfb3807e2017576342
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/368294
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
new file mode 100644
index 0000000..2e88dbf
--- /dev/null
+++ b/cmd/worker/main.go
@@ -0,0 +1,75 @@
+// Copyright 2021 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.
+
+// Command worker runs the vuln worker server.
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"net/http"
+	"os"
+
+	"cloud.google.com/go/errorreporting"
+	"golang.org/x/vuln/internal/derrors"
+	"golang.org/x/vuln/internal/worker"
+	"golang.org/x/vuln/internal/worker/log"
+	"golang.org/x/vuln/internal/worker/store"
+)
+
+var (
+	project        = flag.String("project", os.Getenv("GOOGLE_CLOUD_PROJECT"), "project ID")
+	namespace      = flag.String("namespace", os.Getenv("VULN_WORKER_NAMESPACE"), "Firestore namespace")
+	errorReporting = flag.Bool("reporterrors", os.Getenv("VULN_WORKER_REPORT_ERRORS") == "true", "use the error reporting API")
+)
+
+const serviceID = "vuln-worker"
+
+func main() {
+	flag.Parse()
+	if *project == "" {
+		die("need -project or GOOGLE_CLOUD_PROJECT")
+	}
+	if *namespace == "" {
+		die("need -namespace or VULN_WORKER_NAMESPACE")
+	}
+	if os.Getenv("PORT") == "" && flag.NArg() == 0 {
+		die("need PORT or command-line args")
+	}
+
+	ctx := log.WithLineLogger(context.Background())
+
+	fstore, err := store.NewFireStore(ctx, *project, *namespace)
+	if err != nil {
+		die("firestore: %v", err)
+	}
+
+	if *errorReporting {
+		reportingClient, err := errorreporting.NewClient(ctx, *project, errorreporting.Config{
+			ServiceName: serviceID,
+			OnError: func(err error) {
+				log.Errorf(ctx, "Error reporting failed: %v", err)
+			},
+		})
+		if err != nil {
+			die("errorreporting: %v", err)
+		}
+		derrors.SetReportingClient(reportingClient)
+	}
+
+	_, err = worker.NewServer(ctx, *namespace, fstore)
+	if err != nil {
+		die("NewServer: %v", err)
+	}
+	addr := ":" + os.Getenv("PORT")
+	log.Infof(ctx, "Listening on addr %s", addr)
+	die("listening: %v", http.ListenAndServe(addr, nil))
+}
+
+func die(format string, args ...interface{}) {
+	fmt.Fprintf(os.Stderr, format, args...)
+	fmt.Fprintln(os.Stderr)
+	os.Exit(1)
+}
diff --git a/go.mod b/go.mod
index f8183a0..7ec2e2c 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@
 
 require (
 	cloud.google.com/go v0.97.0 // indirect
+	cloud.google.com/go/errorreporting v0.1.0
 	cloud.google.com/go/firestore v1.6.1
 	github.com/Microsoft/go-winio v0.4.16 // indirect
 	github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect
@@ -21,6 +22,7 @@
 	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.6
+	github.com/google/safehtml v0.0.2
 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect
 	github.com/imdario/mergo v0.3.12 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@@ -46,3 +48,5 @@
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0
 )
+
+require golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
diff --git a/go.sum b/go.sum
index c0f21e2..e93dcf8 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,7 @@
 cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
 cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
 cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
+cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
 cloud.google.com/go v0.97.0 h1:3DXvAyifywvq64LfkKaMOmkWPS1CikIQdMe2lY9vxU8=
@@ -34,6 +35,8 @@
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/errorreporting v0.1.0 h1:z40EhrjRspplwbpO+9DSnC4kgDokBi94T/gYwtdKL5Q=
+cloud.google.com/go/errorreporting v0.1.0/go.mod h1:cZSiBMvrnl0X13pD9DwKf9sQ8Eqy3EzHqkyKBZxiIrM=
 cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
@@ -237,6 +240,8 @@
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/safehtml v0.0.2 h1:ZOt2VXg4x24bW0m2jtzAOkhoXV0iM8vNKc0paByCZqM=
+github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
 github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -593,6 +598,7 @@
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
diff --git a/internal/derrors/derrors.go b/internal/derrors/derrors.go
index c695747..cc1faf0 100644
--- a/internal/derrors/derrors.go
+++ b/internal/derrors/derrors.go
@@ -6,7 +6,11 @@
 // types error semantics supported by x/vuln.
 package derrors
 
-import "fmt"
+import (
+	"fmt"
+
+	"cloud.google.com/go/errorreporting"
+)
 
 // Wrap adds context to the error and allows
 // unwrapping the result to recover the original error.
@@ -19,3 +23,25 @@
 		*errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)
 	}
 }
+
+// WrapAndReport calls Wrap followed by Report.
+func WrapAndReport(errp *error, format string, args ...interface{}) {
+	Wrap(errp, format, args...)
+	if *errp != nil {
+		Report(*errp)
+	}
+}
+
+var repClient *errorreporting.Client
+
+// SetReportingClient sets an errorreporting client, for use by Report.
+func SetReportingClient(c *errorreporting.Client) {
+	repClient = c
+}
+
+// Report uses the errorreporting API to report an error.
+func Report(err error) {
+	if repClient != nil {
+		repClient.Report(errorreporting.Entry{Error: err})
+	}
+}
diff --git a/internal/worker/server.go b/internal/worker/server.go
new file mode 100644
index 0000000..d4d3311
--- /dev/null
+++ b/internal/worker/server.go
@@ -0,0 +1,124 @@
+// Copyright 2021 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 worker
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"net/http"
+	"path/filepath"
+	"time"
+
+	"github.com/google/safehtml/template"
+	"golang.org/x/exp/event"
+	"golang.org/x/vuln/internal/derrors"
+	"golang.org/x/vuln/internal/worker/log"
+	"golang.org/x/vuln/internal/worker/store"
+)
+
+var staticPath = template.TrustedSourceFromConstant("internal/worker/static")
+
+type Server struct {
+	namespace string
+	st        store.Store
+
+	indexTemplate *template.Template
+}
+
+func NewServer(ctx context.Context, namespace string, st store.Store) (_ *Server, err error) {
+	defer derrors.Wrap(&err, "NewServer(%q)", namespace)
+
+	s := &Server{namespace: namespace, st: st}
+	s.indexTemplate, err = parseTemplate(staticPath, template.TrustedSourceFromConstant("index.tmpl"))
+	if err != nil {
+		return nil, err
+	}
+	s.handle(ctx, "/", s.indexPage)
+	http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticPath.String()))))
+	s.handle(ctx, "/favicon.ico", func(w http.ResponseWriter, r *http.Request) error {
+		http.ServeFile(w, r, filepath.Join(staticPath.String(), "favicon.ico"))
+		return nil
+	})
+	return s, nil
+}
+
+func (s *Server) indexPage(w http.ResponseWriter, r *http.Request) error {
+	type data struct {
+		Namespace string
+	}
+	page := data{
+		Namespace: s.namespace,
+	}
+	return renderPage(r.Context(), w, page, s.indexTemplate)
+}
+
+func (s *Server) handle(ctx context.Context, pattern string, handler func(w http.ResponseWriter, r *http.Request) error) {
+	http.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		traceID := r.Header.Get("X-Cloud-Trace-Context")
+
+		log.Info(ctx, "request start",
+			event.Value("httpRequest", r),
+			event.String("traceID", traceID))
+
+		r = r.WithContext(log.WithLineLogger(r.Context()))
+		w2 := &responseWriter{ResponseWriter: w}
+		if err := handler(w2, r); err != nil {
+			s.serveError(ctx, w2, r, err)
+		}
+
+		log.Info(ctx, "request end",
+			event.Value("traceID", traceID),
+			event.Duration("latency", time.Since(start)),
+			event.Int64("status", translateStatus(w2.status)))
+	})
+}
+
+func (s *Server) serveError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
+	errString := err.Error()
+	log.Error(ctx, errString)
+	http.Error(w, errString, http.StatusInternalServerError)
+}
+
+type responseWriter struct {
+	http.ResponseWriter
+	status int
+}
+
+func (rw *responseWriter) WriteHeader(code int) {
+	rw.status = code
+	rw.ResponseWriter.WriteHeader(code)
+}
+
+func translateStatus(code int) int64 {
+	if code == 0 {
+		return http.StatusOK
+	}
+	return int64(code)
+}
+
+// Parse a template.
+func parseTemplate(staticPath, filename template.TrustedSource) (*template.Template, error) {
+	if staticPath.String() == "" {
+		return nil, nil
+	}
+	templatePath := template.TrustedSourceJoin(staticPath, filename)
+	return template.New(filename.String()).ParseFilesFromTrustedSources(templatePath)
+}
+
+func renderPage(ctx context.Context, w http.ResponseWriter, page interface{}, tmpl *template.Template) (err error) {
+	defer derrors.Wrap(&err, "renderPage")
+
+	var buf bytes.Buffer
+	if err := tmpl.Execute(&buf, page); err != nil {
+		return err
+	}
+	if _, err := io.Copy(w, &buf); err != nil {
+		log.Error(ctx, "copying buffer to ResponseWriter", event.Value("error", err))
+		return err
+	}
+	return nil
+}
diff --git a/internal/worker/static/favicon.ico b/internal/worker/static/favicon.ico
new file mode 100644
index 0000000..43dc397
--- /dev/null
+++ b/internal/worker/static/favicon.ico
Binary files differ
diff --git a/internal/worker/static/index.tmpl b/internal/worker/static/index.tmpl
new file mode 100644
index 0000000..85094b1
--- /dev/null
+++ b/internal/worker/static/index.tmpl
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2021 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.
+-->
+
+<!DOCTYPE html>
+<html lang="en">
+<meta charset="utf-8">
+<link href="/static/worker.css" rel="stylesheet">
+<title>{{.Namespace}} Vuln Worker</title>
+
+<body>
+  <h1>{{.Namespace}} Vuln Worker</h1>
+  <p>All times in America/New_York.</p>
+</body>
+</html>
+
+
+
diff --git a/internal/worker/static/worker.css b/internal/worker/static/worker.css
new file mode 100644
index 0000000..81a9f72
--- /dev/null
+++ b/internal/worker/static/worker.css
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2021 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.
+ */
+
+:root {
+  --white: #eee;
+  --gray: #ccc;
+  --red: red;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
+    'Helvetica Neue', Arial, sans-serif;
+}
+label {
+  display: inline-block;
+  text-align: right;
+  width: 12.5rem;
+}
+input {
+  width: 12.5rem;
+}
+button {
+  background-color: var(--white);
+  border: 0.0625rem solid var(--gray);
+  border-radius: 0.125rem;
+  width: 16rem;
+}
+table {
+  border-spacing: 0.625rem 0.125rem;
+  font-size: 0.75rem;
+  padding: 0.1875rem 0 0.125rem 0;
+}
+td {
+  border-top: 0.0625rem solid var(--gray);
+}