cmd/admingolangorg: run under IAP and support playground removals

In locking down our permissions, I blocked access to this site and
x/build/rmplaysnippet. Put the site behind IAP, and add playground
removal support.

Change-Id: I7722f5911f31a140c93780fe0417dd2368276926
Reviewed-on: https://go-review.googlesource.com/c/website/+/435455
Run-TryBot: Heschi Kreinick <heschi@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Auto-Submit: Heschi Kreinick <heschi@google.com>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
diff --git a/cmd/admingolangorg/README.md b/cmd/admingolangorg/README.md
index af4f7b3..dc80664 100644
--- a/cmd/admingolangorg/README.md
+++ b/cmd/admingolangorg/README.md
@@ -1,7 +1,6 @@
 # admingolangorg
 
-This app serves as the [admin interface](https://admin-dot-golang-org.appspot.com) for the go.dev/s link
-shortener. Its functionality may be expanded in the future.
+This app serves as the [admin interface](https://admin-dot-golang-org.appspot.com) for the go.dev/s link shortener. It can also remove unwanted playground snippets.
 
 ## Deployment:
 
diff --git a/cmd/admingolangorg/app.yaml b/cmd/admingolangorg/app.yaml
index 9c77bf9..732c020 100644
--- a/cmd/admingolangorg/app.yaml
+++ b/cmd/admingolangorg/app.yaml
@@ -5,12 +5,12 @@
 env_variables:
   GOLANGORG_REDIS_ADDR: 10.0.0.4:6379 # instance "gophercache"
   DATASTORE_PROJECT_ID: golang-org
+  IAP_AUDIENCE: /projects/397748307997/apps/golang-org
 
 handlers:
   - url: .*
     script: auto
     secure: always
-    login: admin # THIS MUST BE SET
 
 vpc_access_connector:
   name: 'projects/golang-org/locations/us-central1/connectors/golang-vpc-connector'
diff --git a/cmd/admingolangorg/index.html b/cmd/admingolangorg/index.html
new file mode 100644
index 0000000..31a6f28
--- /dev/null
+++ b/cmd/admingolangorg/index.html
@@ -0,0 +1,11 @@
+<!--
+	Copyright 2022 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">
+    <p><a href="/shortlink">administer short links</a></p>
+    <p><a href="/snippet">remove a snippet</a></p>
+</html>
diff --git a/cmd/admingolangorg/main.go b/cmd/admingolangorg/main.go
index a8dd26a..fb358d6 100644
--- a/cmd/admingolangorg/main.go
+++ b/cmd/admingolangorg/main.go
@@ -8,18 +8,34 @@
 
 import (
 	"context"
+	_ "embed"
+	"fmt"
 	"log"
 	"net/http"
 	"os"
 	"strings"
+	"time"
 
 	"cloud.google.com/go/datastore"
 	"golang.org/x/website/internal/memcache"
 	"golang.org/x/website/internal/short"
+	"google.golang.org/api/idtoken"
 )
 
+//go:embed index.html
+var index string
+
 func main() {
-	http.HandleFunc("/", short.AdminHandler(getClients()))
+	audience := os.Getenv("IAP_AUDIENCE")
+	dsClient, mcClient := getClients()
+	mux := http.NewServeMux()
+	mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html")
+		w.Write([]byte(index))
+		return
+	}))
+	mux.Handle("/shortlink", short.AdminHandler(dsClient, mcClient))
+	mux.Handle("/snippet", &snippetHandler{dsClient})
 	port := os.Getenv("PORT")
 	if port == "" {
 		port = "8080"
@@ -27,7 +43,58 @@
 	}
 
 	log.Printf("Listening on port %s", port)
-	log.Fatal(http.ListenAndServe(":"+port, nil))
+	log.Fatal(http.ListenAndServe(":"+port, iapAuth(audience, mux)))
+}
+
+type snippetHandler struct {
+	ds *datastore.Client
+}
+
+//go:embed snippet.html
+var snippetForm string
+
+func (h *snippetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	snippetLink := r.FormValue("snippet")
+	if snippetLink == "" {
+		w.Header().Set("Content-Type", "text/html")
+		w.Write([]byte(snippetForm))
+		return
+	}
+
+	prefixes := []string{
+		"https://play.golang.org/p/",
+		"http://play.golang.org/p/",
+		"https://go.dev/play/p/",
+		"http://go.dev/play/p/",
+	}
+	var snippetID string
+	for _, p := range prefixes {
+		if strings.HasPrefix(snippetLink, p) {
+			snippetID = strings.TrimPrefix(snippetLink, p)
+			break
+		}
+	}
+	if !strings.Contains(snippetLink, "/") {
+		snippetID = snippetLink
+	}
+	if snippetID == "" {
+		w.WriteHeader(http.StatusBadRequest)
+		fmt.Fprintf(w, "must specify snippet URL or ID\n")
+		return
+	}
+
+	k := datastore.NameKey("Snippet", snippetID, nil)
+	if h.ds.Get(r.Context(), k, new(struct{})) == datastore.ErrNoSuchEntity {
+		w.WriteHeader(http.StatusNotFound)
+		fmt.Fprintf(w, "Snippet with ID %q does not exist\n", snippetID)
+		return
+	}
+	if err := h.ds.Delete(r.Context(), k); err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		fmt.Fprintf(w, "Unable to delete Snippet with ID %q: %v\n", snippetID, err)
+		return
+	}
+	w.Write([]byte("snippet deleted\n"))
 }
 
 func getClients() (*datastore.Client, *memcache.Client) {
@@ -49,3 +116,33 @@
 
 	return datastoreClient, memcacheClient
 }
+
+func iapAuth(audience string, h http.Handler) http.Handler {
+	// https://cloud.google.com/iap/docs/signed-headers-howto#verifying_the_jwt_payload
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		jwt := r.Header.Get("x-goog-iap-jwt-assertion")
+		if jwt == "" {
+			w.WriteHeader(http.StatusUnauthorized)
+			fmt.Fprintf(w, "must run under IAP\n")
+			return
+		}
+
+		payload, err := idtoken.Validate(r.Context(), jwt, audience)
+		if err != nil {
+			w.WriteHeader(http.StatusUnauthorized)
+			log.Printf("JWT validation error: %v", err)
+			return
+		}
+		if payload.Issuer != "https://cloud.google.com/iap" {
+			w.WriteHeader(http.StatusUnauthorized)
+			log.Printf("Incorrect issuer: %q", payload.Issuer)
+			return
+		}
+		if payload.Expires+30 < time.Now().Unix() || payload.IssuedAt-30 > time.Now().Unix() {
+			w.WriteHeader(http.StatusUnauthorized)
+			log.Printf("Bad JWT times: expires %v, issued %v", time.Unix(payload.Expires, 0), time.Unix(payload.IssuedAt, 0))
+			return
+		}
+		h.ServeHTTP(w, r)
+	})
+}
diff --git a/cmd/admingolangorg/snippet.html b/cmd/admingolangorg/snippet.html
new file mode 100644
index 0000000..fc51718
--- /dev/null
+++ b/cmd/admingolangorg/snippet.html
@@ -0,0 +1,15 @@
+<!--
+	Copyright 2022 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">
+<p>Delete a snippet</p>
+<form method="POST">
+<label for="snippet">Snippet URL</label>
+<input type="text" name="snippet" id="snippet" placeholder="https://go.dev/play/p/XXXXX" required>
+<input type="submit" name="submit" value="Remove">
+</form>
+</html>