internal/dl: add CORS support to JSON endpoint

This change allows users to request golang.org/dl/?mode=json
via Cross-Origin Resource Sharing (CORS).

It also removes the golangorg build tag as it did not seem
necessary and adds tests for the “include” GET parameter.

Updates golang/go#29206
Fixes golang/go#40253

Change-Id: I5306a264c4ac2a6e6f49cfb53db01eef6b7f4473
Reviewed-on: https://go-review.googlesource.com/c/website/+/243118
Run-TryBot: Andrew Bonventre <andybons@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Alexander Rakoczy <alex@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/internal/dl/server.go b/internal/dl/server.go
index 8b3bd7c..c837584 100644
--- a/internal/dl/server.go
+++ b/internal/dl/server.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// +build golangorg
-
 package dl
 
 import (
@@ -46,7 +44,7 @@
 var rootKey = datastore.NameKey("FileRoot", "root", nil)
 
 func (h server) listHandler(w http.ResponseWriter, r *http.Request) {
-	if r.Method != "GET" {
+	if r.Method != "GET" && r.Method != "OPTIONS" {
 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 		return
 	}
@@ -80,19 +78,7 @@
 	}
 
 	if r.URL.Query().Get("mode") == "json" {
-		var releases []Release
-		switch r.URL.Query().Get("include") {
-		case "all":
-			releases = append(append(d.Stable, d.Archive...), d.Unstable...)
-		default:
-			releases = d.Stable
-		}
-		w.Header().Set("Content-Type", "application/json")
-		enc := json.NewEncoder(w)
-		enc.SetIndent("", " ")
-		if err := enc.Encode(releases); err != nil {
-			log.Printf("ERROR rendering JSON for releases: %v", err)
-		}
+		serveJSON(w, r, d)
 		return
 	}
 
@@ -101,6 +87,32 @@
 	}
 }
 
+// serveJSON serves a JSON representation of d. It assumes that requests are
+// limited to GET and OPTIONS, the latter used for CORS requests, which this
+// endpoint supports.
+func serveJSON(w http.ResponseWriter, r *http.Request, d listTemplateData) {
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+	if r.Method == "OPTIONS" {
+		// Likely a CORS preflight request.
+		w.WriteHeader(http.StatusNoContent)
+		return
+	}
+	var releases []Release
+	switch r.URL.Query().Get("include") {
+	case "all":
+		releases = append(append(d.Stable, d.Archive...), d.Unstable...)
+	default:
+		releases = d.Stable
+	}
+	w.Header().Set("Content-Type", "application/json")
+	enc := json.NewEncoder(w)
+	enc.SetIndent("", " ")
+	if err := enc.Encode(releases); err != nil {
+		log.Printf("ERROR rendering JSON for releases: %v", err)
+	}
+}
+
 // googleCN reports whether request r is considered
 // to be served from golang.google.cn.
 // TODO: This is duplicated within internal/proxy. Move to a common location.
diff --git a/internal/dl/server_test.go b/internal/dl/server_test.go
new file mode 100644
index 0000000..2cff8d7
--- /dev/null
+++ b/internal/dl/server_test.go
@@ -0,0 +1,92 @@
+// Copyright 2020 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 dl
+
+import (
+	"encoding/json"
+	"net/http/httptest"
+	"sort"
+	"testing"
+)
+
+func TestServeJSON(t *testing.T) {
+	data := listTemplateData{
+		Stable:   []Release{{Version: "Stable"}},
+		Unstable: []Release{{Version: "Unstable"}},
+		Archive:  []Release{{Version: "Archived"}},
+	}
+	testCases := []struct {
+		desc     string
+		method   string
+		target   string
+		status   int
+		versions []string
+	}{
+		{
+			desc:     "basic",
+			method:   "GET",
+			target:   "/",
+			status:   200,
+			versions: []string{"Stable"},
+		},
+		{
+			desc:     "include all versions",
+			method:   "GET",
+			target:   "/?include=all",
+			status:   200,
+			versions: []string{"Stable", "Unstable", "Archived"},
+		},
+		{
+			desc:   "CORS preflight request",
+			method: "OPTIONS",
+			target: "/",
+			status: 204,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			r := httptest.NewRequest(tc.method, tc.target, nil)
+			w := httptest.NewRecorder()
+			serveJSON(w, r, data)
+
+			resp := w.Result()
+			defer resp.Body.Close()
+			if got, want := resp.StatusCode, tc.status; got != want {
+				t.Errorf("Response status code = %d; want %d", got, want)
+			}
+			for k, v := range map[string]string{
+				"Access-Control-Allow-Origin":  "*",
+				"Access-Control-Allow-Methods": "GET, OPTIONS",
+			} {
+				if got, want := resp.Header.Get(k), v; got != want {
+					t.Errorf("%s = %q; want %q", k, got, want)
+				}
+			}
+			if tc.versions == nil {
+				return
+			}
+
+			if got, want := resp.Header.Get("Content-Type"), "application/json"; got != want {
+				t.Errorf("Content-Type = %q; want %q", got, want)
+			}
+			var rs []Release
+			if err := json.NewDecoder(resp.Body).Decode(&rs); err != nil {
+				t.Fatalf("json.Decode: got unexpected error: %v", err)
+			}
+			sort.Slice(rs, func(i, j int) bool {
+				return rs[i].Version < rs[j].Version
+			})
+			sort.Strings(tc.versions)
+			if got, want := len(rs), len(tc.versions); got != want {
+				t.Fatalf("Number of releases = %d; want %d", got, want)
+			}
+			for i := range rs {
+				if got, want := rs[i].Version, tc.versions[i]; got != want {
+					t.Errorf("Got version %q; want %q", got, want)
+				}
+			}
+		})
+	}
+}