Merge pull request #338 from aishraj/master

Adding files section to the bottom of command pages
diff --git a/doc/builder.go b/doc/builder.go
index d0e658b..a2b6e14 100644
--- a/doc/builder.go
+++ b/doc/builder.go
@@ -456,6 +456,7 @@
 	{"linux", "amd64"},
 	{"darwin", "amd64"},
 	{"windows", "amd64"},
+	{"linux", "js"},
 }
 
 // SetDefaultGOOS sets given GOOS value as default one to use when building
diff --git a/gddo-server/assets/templates/common.html b/gddo-server/assets/templates/common.html
index 0e59f16..7a46786 100644
--- a/gddo-server/assets/templates/common.html
+++ b/gddo-server/assets/templates/common.html
@@ -100,7 +100,7 @@
 
 {{define "Bootstrap.css"}}<link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css" rel="stylesheet">{{end}}
 {{define "Bootstrap.js"}}<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/js/bootstrap.min.js"></script>{{end}}
-{{define "jQuery"}}<script src="//ajax.googleapis.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>{{end}}
+{{define "jQuery"}}<script src="//code.jquery.com/jquery-2.0.3.min.js"></script>{{end}}
 
 {{define "FlashMessages"}}{{range .}}
   {{if eq .ID "redir"}}{{if eq (len .Args) 1}}<div class="alert alert-warning">Redirected from {{index .Args 0}}.</div>{{end}}
diff --git a/gddo-server/client.go b/gddo-server/client.go
index 96ad2af..75b7c6f 100644
--- a/gddo-server/client.go
+++ b/gddo-server/client.go
@@ -11,10 +11,11 @@
 
 import (
 	"flag"
-	"log"
 	"net"
 	"net/http"
 	"time"
+
+	"github.com/golang/gddo/httputil"
 )
 
 var (
@@ -22,57 +23,19 @@
 	requestTimeout = flag.Duration("request_timeout", 20*time.Second, "Time out for roundtripping an HTTP request.")
 )
 
-type timeoutConn struct {
-	net.Conn
-}
-
-func (c timeoutConn) Read(p []byte) (int, error) {
-	n, err := c.Conn.Read(p)
-	c.Conn.SetReadDeadline(time.Time{})
-	return n, err
-}
-
-func timeoutDial(network, addr string) (net.Conn, error) {
-	c, err := net.DialTimeout(network, addr, *dialTimeout)
-	if err != nil {
-		return c, err
+func newHTTPClient() *http.Client {
+	return &http.Client{
+		Transport: httputil.NewAuthTransport(
+			&http.Transport{
+				Proxy: http.ProxyFromEnvironment,
+				Dial: (&net.Dialer{
+					Timeout:   *dialTimeout,
+					KeepAlive: *requestTimeout / 2,
+				}).Dial,
+				ResponseHeaderTimeout: *requestTimeout / 2,
+				TLSHandshakeTimeout:   *requestTimeout / 2,
+			},
+		),
+		Timeout: *requestTimeout,
 	}
-	// The net/http transport CancelRequest feature does not work until after
-	// the TLS handshake is complete. To help catch hangs during the TLS
-	// handshake, we set a deadline on the connection here and clear the
-	// deadline when the first read on the connection completes. This is not
-	// perfect, but it does catch the case where the server accepts and ignores
-	// a connection.
-	c.SetDeadline(time.Now().Add(*requestTimeout))
-	return timeoutConn{c}, nil
 }
-
-type transport struct {
-	t http.Transport
-}
-
-func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
-	timer := time.AfterFunc(*requestTimeout, func() {
-		t.t.CancelRequest(req)
-		log.Printf("Canceled request for %s", req.URL)
-	})
-	defer timer.Stop()
-	if req.URL.Host == "api.github.com" && gitHubCredentials != "" {
-		if req.URL.RawQuery == "" {
-			req.URL.RawQuery = gitHubCredentials
-		} else {
-			req.URL.RawQuery += "&" + gitHubCredentials
-		}
-	}
-	if userAgent != "" {
-		req.Header.Set("User-Agent", userAgent)
-	}
-	return t.t.RoundTrip(req)
-}
-
-var httpClient = &http.Client{Transport: &transport{
-	t: http.Transport{
-		Proxy: http.ProxyFromEnvironment,
-		Dial:  timeoutDial,
-		ResponseHeaderTimeout: *requestTimeout / 2,
-	}}}
diff --git a/gddo-server/config.go.template b/gddo-server/config.go.template
deleted file mode 100644
index efea18b..0000000
--- a/gddo-server/config.go.template
+++ /dev/null
@@ -1,7 +0,0 @@
-package main
-
-func init() {
-	// Register an application at https://github.com/settings/applications/new
-	// and enter the client ID and client secret here.
-	gitHubCredentials = "client_id=<id>&client_secret=<secret>"
-}
diff --git a/gddo-server/main.go b/gddo-server/main.go
index 816971a..ead09cf 100644
--- a/gddo-server/main.go
+++ b/gddo-server/main.go
@@ -811,28 +811,29 @@
 
 var (
 	db                    *database.Database
+	httpClient            *http.Client
 	statusImageHandlerPNG http.Handler
 	statusImageHandlerSVG http.Handler
 )
 
 var (
-	robot             = flag.Float64("robot", 100, "Request counter threshold for robots.")
-	assetsDir         = flag.String("assets", filepath.Join(defaultBase("github.com/golang/gddo/gddo-server"), "assets"), "Base directory for templates and static files.")
-	getTimeout        = flag.Duration("get_timeout", 8*time.Second, "Time to wait for package update from the VCS.")
-	firstGetTimeout   = flag.Duration("first_get_timeout", 5*time.Second, "Time to wait for first fetch of package from the VCS.")
-	maxAge            = flag.Duration("max_age", 24*time.Hour, "Update package documents older than this age.")
-	httpAddr          = flag.String("http", ":8080", "Listen for HTTP connections on this address.")
-	sidebarEnabled    = flag.Bool("sidebar", false, "Enable package page sidebar.")
-	defaultGOOS       = flag.String("default_goos", "", "Default GOOS to use when building package documents.")
-	gitHubCredentials = ""
-	userAgent         = ""
+	robot           = flag.Float64("robot", 100, "Request counter threshold for robots.")
+	assetsDir       = flag.String("assets", filepath.Join(defaultBase("github.com/golang/gddo/gddo-server"), "assets"), "Base directory for templates and static files.")
+	getTimeout      = flag.Duration("get_timeout", 8*time.Second, "Time to wait for package update from the VCS.")
+	firstGetTimeout = flag.Duration("first_get_timeout", 5*time.Second, "Time to wait for first fetch of package from the VCS.")
+	maxAge          = flag.Duration("max_age", 24*time.Hour, "Update package documents older than this age.")
+	httpAddr        = flag.String("http", ":8080", "Listen for HTTP connections on this address.")
+	sidebarEnabled  = flag.Bool("sidebar", false, "Enable package page sidebar.")
+	defaultGOOS     = flag.String("default_goos", "", "Default GOOS to use when building package documents.")
 )
 
 func main() {
 	flag.Parse()
-	doc.SetDefaultGOOS(*defaultGOOS)
 	log.Printf("Starting server, os.Args=%s", strings.Join(os.Args, " "))
 
+	doc.SetDefaultGOOS(*defaultGOOS)
+	httpClient = newHTTPClient()
+
 	if err := parseHTMLTemplates([][]string{
 		{"about.html", "common.html", "layout.html"},
 		{"bot.html", "common.html", "layout.html"},
@@ -918,7 +919,7 @@
 	mux.Handle("/robots.txt", staticServer.FileHandler("robots.txt"))
 	mux.Handle("/BingSiteAuth.xml", staticServer.FileHandler("BingSiteAuth.xml"))
 	mux.Handle("/C", http.RedirectHandler("http://golang.org/doc/articles/c_go_cgo.html", http.StatusMovedPermanently))
-	mux.Handle("/ajax.googleapis.com/", http.NotFoundHandler())
+	mux.Handle("/code.jquery.com/", http.NotFoundHandler())
 	mux.Handle("/", handler(serveHome))
 
 	cacheBusters.Handler = mux
diff --git a/gosrc/github.go b/gosrc/github.go
index 181d938..0de8968 100644
--- a/gosrc/github.go
+++ b/gosrc/github.go
@@ -18,7 +18,7 @@
 
 func init() {
 	addService(&service{
-		pattern:         regexp.MustCompile(`^github\.com/(?P<owner>[a-z0-9A-Z_.\-]+)/(?P<repo>[a-z0-9A-Z_.\-]+)(?P<dir>/[^/]*)?$`),
+		pattern:         regexp.MustCompile(`^github\.com/(?P<owner>[a-z0-9A-Z_.\-]+)/(?P<repo>[a-z0-9A-Z_.\-]+)(?P<dir>/.*)?$`),
 		prefix:          "github.com/",
 		get:             getGitHubDir,
 		getPresentation: getGitHubPresentation,
diff --git a/gosrc/gosrc.go b/gosrc/gosrc.go
index 9ae9fca..41b41b3 100644
--- a/gosrc/gosrc.go
+++ b/gosrc/gosrc.go
@@ -324,13 +324,17 @@
 		}
 	}
 
-	repo := strings.TrimSuffix(im.repo, "."+im.vcs)
-	i := strings.Index(repo, "://")
+	// clonePath is the repo URL from import meta tag, with the "scheme://" prefix removed.
+	// It should be used for cloning repositories.
+	// repo is the repo URL from import meta tag, with the "scheme://" prefix removed, and
+	// a possible ".vcs" suffix trimmed.
+	i := strings.Index(im.repo, "://")
 	if i < 0 {
 		return nil, NotFoundError{Message: "bad repo URL: " + im.repo}
 	}
-	proto := repo[:i]
-	repo = repo[i+len("://"):]
+	proto := im.repo[:i]
+	clonePath := im.repo[i+len("://"):]
+	repo := strings.TrimSuffix(clonePath, "."+im.vcs)
 	dirName := importPath[len(im.projectRoot):]
 
 	resolvedPath := repo + dirName
@@ -340,6 +344,7 @@
 		match := map[string]string{
 			"dir":        dirName,
 			"importPath": importPath,
+			"clonePath":  clonePath,
 			"repo":       repo,
 			"scheme":     proto,
 			"vcs":        im.vcs,
diff --git a/gosrc/path.go b/gosrc/path.go
index 6575df8..8219d68 100644
--- a/gosrc/path.go
+++ b/gosrc/path.go
@@ -26,11 +26,6 @@
 
 	parts := strings.Split(importPath, "/")
 
-	if len(parts) <= 1 {
-		// Import path must contain at least one "/".
-		return false
-	}
-
 	if !validTLDs[path.Ext(parts[0])] {
 		return false
 	}
diff --git a/gosrc/path_test.go b/gosrc/path_test.go
index be9fc0f..965b75b 100644
--- a/gosrc/path_test.go
+++ b/gosrc/path_test.go
@@ -20,6 +20,7 @@
 	"launchpad.net/~user/foo/trunk",
 	"launchpad.net/~user/+junk/version",
 	"github.com/user/repo/_ok/x",
+	"exampleproject.com",
 }
 
 var badImportPaths = []string{
@@ -27,7 +28,6 @@
 	"foo.",
 	".bar",
 	"favicon.ico",
-	"exmpple.com",
 	"github.com/user/repo/.ignore/x",
 }
 
@@ -36,7 +36,15 @@
 		if !IsValidRemotePath(importPath) {
 			t.Errorf("isBadImportPath(%q) -> true, want false", importPath)
 		}
+
+		for _, s := range services {
+			if _, err := s.match(importPath); err != nil {
+				t.Errorf("match(%#v) → error %v", importPath, err)
+				break
+			}
+		}
 	}
+
 	for _, importPath := range badImportPaths {
 		if IsValidRemotePath(importPath) {
 			t.Errorf("isBadImportPath(%q) -> false, want true", importPath)
diff --git a/gosrc/vcs.go b/gosrc/vcs.go
index 95e7c4a..db0c083 100644
--- a/gosrc/vcs.go
+++ b/gosrc/vcs.go
@@ -101,7 +101,7 @@
 
 type vcsCmd struct {
 	schemes  []string
-	download func([]string, string, string) (string, string, error)
+	download func(schemes []string, clonePath, repo, savedEtag string) (tag, etag string, err error)
 }
 
 var vcsCmds = map[string]*vcsCmd{
@@ -117,11 +117,11 @@
 
 var lsremoteRe = regexp.MustCompile(`(?m)^([0-9a-f]{40})\s+refs/(?:tags|heads)/(.+)$`)
 
-func downloadGit(schemes []string, repo, savedEtag string) (string, string, error) {
+func downloadGit(schemes []string, clonePath, repo, savedEtag string) (string, string, error) {
 	var p []byte
 	var scheme string
 	for i := range schemes {
-		cmd := exec.Command("git", "ls-remote", "--heads", "--tags", schemes[i]+"://"+repo+".git")
+		cmd := exec.Command("git", "ls-remote", "--heads", "--tags", schemes[i]+"://"+clonePath)
 		log.Println(strings.Join(cmd.Args, " "))
 		var err error
 		p, err = outputWithTimeout(cmd, lsRemoteTimeout)
@@ -151,14 +151,14 @@
 		return "", "", ErrNotModified
 	}
 
-	dir := path.Join(TempDir, repo+".git")
-	p, err = ioutil.ReadFile(path.Join(dir, ".git/HEAD"))
+	dir := filepath.Join(TempDir, repo+".git")
+	p, err = ioutil.ReadFile(filepath.Join(dir, ".git", "HEAD"))
 	switch {
 	case err != nil:
 		if err := os.MkdirAll(dir, 0777); err != nil {
 			return "", "", err
 		}
-		cmd := exec.Command("git", "clone", scheme+"://"+repo+".git", dir)
+		cmd := exec.Command("git", "clone", scheme+"://"+clonePath, dir)
 		log.Println(strings.Join(cmd.Args, " "))
 		if err := runWithTimeout(cmd, cloneTimeout); err != nil {
 			return "", "", err
@@ -183,12 +183,12 @@
 	return tag, etag, nil
 }
 
-func downloadSVN(schemes []string, repo, savedEtag string) (string, string, error) {
+func downloadSVN(schemes []string, clonePath, repo, savedEtag string) (string, string, error) {
 	var scheme string
 	var revno string
 	for i := range schemes {
 		var err error
-		revno, err = getSVNRevision(schemes[i] + "://" + repo)
+		revno, err = getSVNRevision(schemes[i] + "://" + clonePath)
 		if err == nil {
 			scheme = schemes[i]
 			break
@@ -212,7 +212,7 @@
 		if err := os.MkdirAll(dir, 0777); err != nil {
 			return "", "", err
 		}
-		cmd := exec.Command("svn", "checkout", scheme+"://"+repo, "-r", revno, dir)
+		cmd := exec.Command("svn", "checkout", scheme+"://"+clonePath, "-r", revno, dir)
 		log.Println(strings.Join(cmd.Args, " "))
 		if err := runWithTimeout(cmd, cloneTimeout); err != nil {
 			return "", "", err
@@ -271,7 +271,7 @@
 
 	// Download and checkout.
 
-	tag, etag, err := cmd.download(schemes, match["repo"], etagSaved)
+	tag, etag, err := cmd.download(schemes, match["clonePath"], match["repo"], etagSaved)
 	if err != nil {
 		return nil, err
 	}
@@ -282,7 +282,7 @@
 
 	// Slurp source files.
 
-	d := path.Join(TempDir, expand("{repo}.{vcs}", match), match["dir"])
+	d := filepath.Join(TempDir, filepath.FromSlash(expand("{repo}.{vcs}", match)), filepath.FromSlash(match["dir"]))
 	f, err := os.Open(d)
 	if err != nil {
 		if os.IsNotExist(err) {
@@ -304,7 +304,7 @@
 				subdirs = append(subdirs, fi.Name())
 			}
 		case isDocFile(fi.Name()):
-			b, err := ioutil.ReadFile(path.Join(d, fi.Name()))
+			b, err := ioutil.ReadFile(filepath.Join(d, fi.Name()))
 			if err != nil {
 				return nil, err
 			}
diff --git a/httputil/transport.go b/httputil/transport.go
new file mode 100644
index 0000000..4823a87
--- /dev/null
+++ b/httputil/transport.go
@@ -0,0 +1,134 @@
+// Copyright 2015 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 or at
+// https://developers.google.com/open-source/licenses/bsd.
+
+// This file implements a http.RoundTripper that authenticates
+// requests issued against api.github.com endpoint.
+
+package httputil
+
+import (
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+
+	"google.golang.org/cloud/compute/metadata"
+)
+
+// AuthTransport is an implementation of http.RoundTripper that authenticates
+// with the GitHub API.
+//
+// When both a token and client credentials are set, the latter is preferred.
+type AuthTransport struct {
+	UserAgent    string
+	Token        string
+	ClientID     string
+	ClientSecret string
+	Base         http.RoundTripper
+}
+
+// NewAuthTransport gives new AuthTransport created with GitHub credentials
+// read from GCE metadata when the metadata server is accessible (we're on GCE)
+// or read from environment varialbes otherwise.
+func NewAuthTransport(base http.RoundTripper) *AuthTransport {
+	if metadata.OnGCE() {
+		return NewAuthTransportFromMetadata(base)
+	}
+	return NewAuthTransportFromEnvironment(base)
+}
+
+// NewAuthTransportFromEnvironment gives new AuthTransport created with GitHub
+// credentials read from environment variables.
+func NewAuthTransportFromEnvironment(base http.RoundTripper) *AuthTransport {
+	return &AuthTransport{
+		UserAgent:    os.Getenv("USER_AGENT"),
+		Token:        os.Getenv("GITHUB_TOKEN"),
+		ClientID:     os.Getenv("GITHUB_CLIENT_ID"),
+		ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
+		Base:         base,
+	}
+}
+
+// NewAuthTransportFromMetadata gives new AuthTransport created with GitHub
+// credentials read from GCE metadata.
+func NewAuthTransportFromMetadata(base http.RoundTripper) *AuthTransport {
+	return &AuthTransport{
+		UserAgent:    gceAttr("user-agent"),
+		Token:        gceAttr("github-token"),
+		ClientID:     gceAttr("github-client-id"),
+		ClientSecret: gceAttr("github-client-secret"),
+		Base:         base,
+	}
+}
+
+// RoundTrip implements the http.RoundTripper interface.
+func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	var reqCopy *http.Request
+	if t.UserAgent != "" {
+		reqCopy = copyRequest(req)
+		reqCopy.Header.Set("User-Agent", t.UserAgent)
+	}
+	if req.URL.Host == "api.github.com" {
+		switch {
+		case t.ClientID != "" && t.ClientSecret != "":
+			if reqCopy == nil {
+				reqCopy = copyRequest(req)
+			}
+			if reqCopy.URL.RawQuery == "" {
+				reqCopy.URL.RawQuery = "client_id=" + t.ClientID + "&client_secret=" + t.ClientSecret
+			} else {
+				reqCopy.URL.RawQuery += "&client_id=" + t.ClientID + "&client_secret=" + t.ClientSecret
+			}
+		case t.Token != "":
+			if reqCopy == nil {
+				reqCopy = copyRequest(req)
+			}
+			reqCopy.Header.Set("Authorization", "token "+t.Token)
+		}
+	}
+	if reqCopy != nil {
+		return t.base().RoundTrip(reqCopy)
+	}
+	return t.base().RoundTrip(req)
+}
+
+// CancelRequest cancels an in-flight request by closing its connection.
+func (c *AuthTransport) CancelRequest(req *http.Request) {
+	type canceler interface {
+		CancelRequest(req *http.Request)
+	}
+	if cr, ok := c.base().(canceler); ok {
+		cr.CancelRequest(req)
+	}
+}
+
+func (t *AuthTransport) base() http.RoundTripper {
+	if t.Base != nil {
+		return t.Base
+	}
+	return http.DefaultTransport
+}
+
+func gceAttr(name string) string {
+	s, err := metadata.ProjectAttributeValue(name)
+	if err != nil {
+		log.Printf("error querying metadata for %q: %s", name, err)
+		return ""
+	}
+	return s
+}
+
+func copyRequest(req *http.Request) *http.Request {
+	req2 := new(http.Request)
+	*req2 = *req
+	req2.URL = new(url.URL)
+	*req2.URL = *req.URL
+	req2.Header = make(http.Header, len(req.Header))
+	for k, s := range req.Header {
+		req2.Header[k] = append([]string(nil), s...)
+	}
+	return req2
+}
diff --git a/lintapp/README.md b/lintapp/README.md
index 0edff47..93577df 100644
--- a/lintapp/README.md
+++ b/lintapp/README.md
@@ -6,6 +6,6 @@
 Development Environment Setup
 -----------------------------
 
-- Copy config.go.template to config.go and edit the file as described in the comments.
-- Install Go App Engine SDK 
-- Run the server using the dev_appserver command.
+- Copy `app.yaml` to `prod.yaml` and put in the authentication data.
+- Install Go App Engine SDK.
+- Run the server using the `goapp serve prod.yaml` command.
diff --git a/lintapp/app.yaml b/lintapp/app.yaml
index 9894f1b..878da17 100644
--- a/lintapp/app.yaml
+++ b/lintapp/app.yaml
@@ -14,3 +14,9 @@
 
 - url: /.*
   script: _go_app
+
+env_variables:
+  CONTACT_EMAIL: ''        # set contact email for /-/bot.html
+  GITHUB_CLIENT_ID: ''     # used to increase rate-limits; see https://github.com/settings/applications/new
+  GITHUB_CLIENT_SECRET: '' # used to increase rate-limits; see https://github.com/settings/applications/new
+  GITHUB_TOKEN: ''         # personal token used for authentication; see https://github.com/settings/tokens/new
diff --git a/lintapp/config.go.template b/lintapp/config.go.template
deleted file mode 100644
index e3579fa..0000000
--- a/lintapp/config.go.template
+++ /dev/null
@@ -1,10 +0,0 @@
-package lintapp
-
-func init() {
-	// Register an application at https://github.com/settings/applications/new
-	// and enter the client ID and client secret here.
-	gitHubCredentials = "client_id=<id>&client_secret=<secret>"
-
-	// Set contact email for /-/bot.html
-	contactEmail = "example@example.com"
-}
diff --git a/lintapp/main.go b/lintapp/main.go
index 9a76640..14fc690 100644
--- a/lintapp/main.go
+++ b/lintapp/main.go
@@ -13,6 +13,7 @@
 	"fmt"
 	"html/template"
 	"net/http"
+	"os"
 	"path/filepath"
 	"strconv"
 	"strings"
@@ -23,6 +24,8 @@
 	"appengine/urlfetch"
 
 	"github.com/golang/gddo/gosrc"
+	"github.com/golang/gddo/httputil"
+
 	"github.com/golang/lint"
 )
 
@@ -30,6 +33,9 @@
 	http.Handle("/", handlerFunc(serveRoot))
 	http.Handle("/-/bot", handlerFunc(serveBot))
 	http.Handle("/-/refresh", handlerFunc(serveRefresh))
+	if s := os.Getenv("CONTACT_EMAIL"); s != "" {
+		contactEmail = s
+	}
 }
 
 var (
@@ -41,7 +47,7 @@
 		"timeago":      timeagoFn,
 		"contactEmail": contactEmailFn,
 	}
-	gitHubCredentials = ""
+	github = httputil.NewAuthTransportFromEnvironment(nil)
 )
 
 func parseTemplate(fnames ...string) *template.Template {
@@ -102,29 +108,15 @@
 	return writeResponse(w, status, errorTemplate, http.StatusText(status))
 }
 
-type transport struct {
-	rt http.RoundTripper
-	ua string
-}
-
-func (t transport) RoundTrip(r *http.Request) (*http.Response, error) {
-	r.Header.Set("User-Agent", t.ua)
-	if r.URL.Host == "api.github.com" && gitHubCredentials != "" {
-		if r.URL.RawQuery == "" {
-			r.URL.RawQuery = gitHubCredentials
-		} else {
-			r.URL.RawQuery += "&" + gitHubCredentials
-		}
-	}
-	return t.rt.RoundTrip(r)
-}
-
 func httpClient(r *http.Request) *http.Client {
 	c := appengine.NewContext(r)
 	return &http.Client{
-		Transport: &transport{
-			rt: &urlfetch.Transport{Context: c, Deadline: 10 * time.Second},
-			ua: fmt.Sprintf("%s (+http://%s/-/bot)", appengine.AppID(c), r.Host),
+		Transport: &httputil.Transport{
+			Token:        github.Token,
+			ClientID:     github.ClientID,
+			ClientSecret: github.ClientSecret,
+			Base:         &urlfetch.Transport{Context: c, Deadline: 10 * time.Second},
+			UserAgent:    fmt.Sprintf("%s (+http://%s/-/bot)", appengine.AppID(c), r.Host),
 		},
 	}
 }
diff --git a/talksapp/README.md b/talksapp/README.md
index 5e77c6f..2c47a2f 100644
--- a/talksapp/README.md
+++ b/talksapp/README.md
@@ -6,7 +6,8 @@
 Development Environment Setup
 -----------------------------
 
-- Copy config.go.template to config.go and edit the file as described in the comments.
-- Install Go App Engine SDK 
-- $ sh setup.sh 
-- Run the server using the dev_appserver command.
+- Copy `app.yaml` to `prod.yaml` and put in the authentication data.
+- Install Go App Engine SDK.
+- `$ sh setup.sh`
+- Run the server using the `goapp serve prod.yaml` command.
+- Run the tests using the `goapp test` command.
diff --git a/talksapp/app.yaml b/talksapp/app.yaml
index 2e9b1d6..58491b7 100644
--- a/talksapp/app.yaml
+++ b/talksapp/app.yaml
@@ -17,3 +17,9 @@
   upload: present/play.js
 - url: /.*
   script: _go_app
+
+env_variables:
+  CONTACT_EMAIL: ''        # set contact email for /-/bot.html
+  GITHUB_CLIENT_ID: ''     # used to increase rate-limits; see https://github.com/settings/applications/new
+  GITHUB_CLIENT_SECRET: '' # used to increase rate-limits; see https://github.com/settings/applications/new
+  GITHUB_TOKEN: ''         # personal token used for authentication; see https://github.com/settings/tokens/new
diff --git a/talksapp/main.go b/talksapp/main.go
index 117bf5e..d104455 100644
--- a/talksapp/main.go
+++ b/talksapp/main.go
@@ -9,14 +9,11 @@
 
 import (
 	"bytes"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"html/template"
 	"io"
-	"log"
 	"net/http"
-	"net/url"
 	"os"
 	"path"
 	"time"
@@ -25,8 +22,10 @@
 	"appengine/memcache"
 	"appengine/urlfetch"
 
-	"code.google.com/p/go.tools/present"
 	"github.com/golang/gddo/gosrc"
+	"github.com/golang/gddo/httputil"
+
+	"golang.org/x/tools/present"
 )
 
 var (
@@ -34,9 +33,13 @@
 		".article": parsePresentTemplate("article.tmpl"),
 		".slide":   parsePresentTemplate("slides.tmpl"),
 	}
-	homeArticle       = loadHomeArticle()
-	contactEmail      = "golang-dev@googlegroups.com"
-	githubCredentials = parseGithubCredentials()
+	homeArticle  = loadHomeArticle()
+	contactEmail = "golang-dev@googlegroups.com"
+	github       = httputil.NewAuthTransportFromEnvironment(nil)
+
+	// used for mocking in tests
+	getPresentation = gosrc.GetPresentation
+	playCompileURL  = "http://play.golang.org/compile"
 )
 
 func init() {
@@ -44,25 +47,9 @@
 	http.Handle("/compile", handlerFunc(serveCompile))
 	http.Handle("/bot.html", handlerFunc(serveBot))
 	present.PlayEnabled = true
-}
-
-func parseGithubCredentials() string {
-	f, err := os.Open("secret.json")
-	if err != nil {
-		log.Fatalf("open github credentials file secret.json: %v", err)
+	if s := os.Getenv("CONTACT_EMAIL"); s != "" {
+		contactEmail = s
 	}
-	defer f.Close()
-	var cred struct{ ClientID, ClientSecret string }
-	if err := json.NewDecoder(f).Decode(&cred); err != nil {
-		log.Fatalf("parse github credentials: %v", err)
-	}
-	if cred.ClientID == "" || cred.ClientSecret == "" {
-		log.Fatalf("secret.json needs to define ClientID and ClientSecret")
-	}
-	return url.Values{
-		"client_id":     {cred.ClientID},
-		"client_secret": {cred.ClientSecret},
-	}.Encode()
 }
 
 func playable(c present.Code) bool {
@@ -131,29 +118,15 @@
 	w.WriteHeader(status)
 }
 
-type transport struct {
-	rt http.RoundTripper
-	ua string
-}
-
-func (t transport) RoundTrip(r *http.Request) (*http.Response, error) {
-	r.Header.Set("User-Agent", t.ua)
-	if r.URL.Host == "api.github.com" {
-		if r.URL.RawQuery == "" {
-			r.URL.RawQuery = githubCredentials
-		} else {
-			r.URL.RawQuery += "&" + githubCredentials
-		}
-	}
-	return t.rt.RoundTrip(r)
-}
-
 func httpClient(r *http.Request) *http.Client {
 	c := appengine.NewContext(r)
 	return &http.Client{
-		Transport: &transport{
-			rt: &urlfetch.Transport{Context: c, Deadline: 10 * time.Second},
-			ua: fmt.Sprintf("%s (+http://%s/bot.html)", appengine.AppID(c), r.Host),
+		Transport: &httputil.AuthTransport{
+			Token:        github.Token,
+			ClientID:     github.ClientID,
+			ClientSecret: github.ClientSecret,
+			Base:         &urlfetch.Transport{Context: c, Deadline: 10 * time.Second},
+			UserAgent:    fmt.Sprintf("%s (+http://%s/-/bot)", appengine.AppID(c), r.Host),
 		},
 	}
 }
@@ -211,7 +184,7 @@
 	}
 
 	c.Infof("Fetching presentation %s.", importPath)
-	pres, err := gosrc.GetPresentation(httpClient(r), importPath)
+	pres, err := getPresentation(httpClient(r), importPath)
 	if err != nil {
 		return err
 	}
@@ -253,7 +226,7 @@
 	if err := r.ParseForm(); err != nil {
 		return err
 	}
-	resp, err := client.PostForm("http://play.golang.org/compile", r.Form)
+	resp, err := client.PostForm(playCompileURL, r.Form)
 	if err != nil {
 		return err
 	}
diff --git a/talksapp/main_test.go b/talksapp/main_test.go
new file mode 100644
index 0000000..936143f
--- /dev/null
+++ b/talksapp/main_test.go
@@ -0,0 +1,236 @@
+// Copyright 2015 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 or at
+// https://developers.google.com/open-source/licenses/bsd.
+
+package talksapp
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/golang/gddo/gosrc"
+
+	"appengine"
+	"appengine/aetest"
+	"appengine/memcache"
+)
+
+const importPath = "github.com/user/repo/path/to/presentation.slide"
+
+func TestHome(t *testing.T) {
+	do(t, "GET", "/", func(r *http.Request) {
+		w := httptest.NewRecorder()
+		handlerFunc(serveRoot).ServeHTTP(w, r)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status: %d, got: %d", http.StatusOK, w.Code)
+		}
+
+		if !strings.Contains(w.Body.String(), "go-talks.appspot.org") {
+			t.Fatal("expected response to contain: go-talks.appspot.org")
+		}
+	})
+}
+
+func TestPresentation(t *testing.T) {
+	presentationTitle := "My awesome presentation!"
+	presentationSrc := []byte(presentationTitle + `
+
+Subtitle
+
+* Slide 1
+
+- Foo
+- Bar
+- Baz
+`)
+
+	originalGetPresentation := getPresentation
+	getPresentation = func(client *http.Client, importPath string) (*gosrc.Presentation, error) {
+		return &gosrc.Presentation{
+			Filename: "presentation.slide",
+			Files: map[string][]byte{
+				"presentation.slide": presentationSrc,
+			},
+		}, nil
+	}
+	defer func() {
+		getPresentation = originalGetPresentation
+	}()
+
+	do(t, "GET", "/"+importPath, func(r *http.Request) {
+		w := httptest.NewRecorder()
+		handlerFunc(serveRoot).ServeHTTP(w, r)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status: %d, got: %d", http.StatusOK, w.Code)
+		}
+
+		if !strings.Contains(w.Body.String(), presentationTitle) {
+			t.Fatalf("unexpected response body: %s", w.Body)
+		}
+
+		c := appengine.NewContext(r)
+		_, err := memcache.Get(c, importPath)
+
+		if err == memcache.ErrCacheMiss {
+			t.Fatal("expected result to be cached")
+		}
+
+		if err != nil {
+			t.Fatalf("expected no error, got: %s", err)
+		}
+	})
+}
+
+func TestPresentationCacheHit(t *testing.T) {
+	do(t, "GET", "/"+importPath, func(r *http.Request) {
+		cachedPresentation := "<div>My Presentation</div>"
+
+		c := appengine.NewContext(r)
+		memcache.Add(c, &memcache.Item{
+			Key:        importPath,
+			Value:      []byte(cachedPresentation),
+			Expiration: time.Hour,
+		})
+
+		w := httptest.NewRecorder()
+		handlerFunc(serveRoot).ServeHTTP(w, r)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status: %d, got: %d", http.StatusOK, w.Code)
+		}
+
+		if w.Body.String() != cachedPresentation {
+			t.Fatal("response does not matched cached presentation")
+		}
+	})
+}
+
+func TestPresentationNotFound(t *testing.T) {
+	originalGetPresentation := getPresentation
+	getPresentation = func(client *http.Client, importPath string) (*gosrc.Presentation, error) {
+		return nil, gosrc.NotFoundError{}
+	}
+	defer func() {
+		getPresentation = originalGetPresentation
+	}()
+
+	do(t, "GET", "/"+importPath, func(r *http.Request) {
+		w := httptest.NewRecorder()
+		handlerFunc(serveRoot).ServeHTTP(w, r)
+
+		if w.Code != http.StatusBadRequest {
+			t.Fatalf("expected status: %d, got: %d", http.StatusBadRequest, w.Code)
+		}
+	})
+}
+
+func TestWrongMethod(t *testing.T) {
+	do(t, "POST", "/", func(r *http.Request) {
+		w := httptest.NewRecorder()
+		handlerFunc(serveRoot).ServeHTTP(w, r)
+
+		if w.Code != http.StatusMethodNotAllowed {
+			t.Fatalf("expected status %d", http.StatusMethodNotAllowed)
+		}
+	})
+}
+
+func TestCompile(t *testing.T) {
+	version := "2"
+	body := `
+	package main
+
+	import "fmt"
+
+	func main() {
+		fmt.fmtPrintln("Hello, playground")
+	}
+	`
+	responseJSON := `{"Errors":"","Events":[{"Message":"Hello, playground\n","Kind":"stdout","Delay":0}]}`
+
+	server := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			formVersion := r.FormValue("version")
+			formBody := r.FormValue("body")
+
+			if formVersion != version {
+				t.Fatalf("expected version sent to play.golang.org to be: %s, was: %s", version, formVersion)
+			}
+
+			if formBody != body {
+				t.Fatalf("expected body sent to play.golang.org to be: %s, was: %s", body, formBody)
+			}
+
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(200)
+
+			fmt.Fprintln(w, responseJSON)
+		}),
+	)
+	defer server.Close()
+
+	defer func(old string) { playCompileURL = old }(playCompileURL)
+	playCompileURL = server.URL
+
+	do(t, "POST", "/compile", func(r *http.Request) {
+		r.PostForm = url.Values{
+			"version": []string{version},
+			"body":    []string{body},
+		}
+
+		w := httptest.NewRecorder()
+		handlerFunc(serveCompile).ServeHTTP(w, r)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status: %d, got: %d", http.StatusOK, w.Code)
+		}
+
+		contentType := w.Header().Get("Content-Type")
+		if w.Header().Get("Content-Type") != "application/json" {
+			t.Fatalf("unexpected Content-Type: %s", contentType)
+		}
+
+		if strings.TrimSpace(w.Body.String()) != responseJSON {
+			t.Fatalf("unexpected response body: %s", w.Body)
+		}
+	})
+}
+
+func TestBot(t *testing.T) {
+	do(t, "GET", "/bot.html", func(r *http.Request) {
+		w := httptest.NewRecorder()
+		handlerFunc(serveBot).ServeHTTP(w, r)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status: %d, got: %d", http.StatusOK, w.Code)
+		}
+
+		if !strings.Contains(w.Body.String(), contactEmail) {
+			t.Fatalf("expected body to contain %s", contactEmail)
+		}
+	})
+}
+
+func do(t *testing.T, method, path string, f func(*http.Request)) {
+	i, err := aetest.NewInstance(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer i.Close()
+
+	r, err := i.NewRequest(method, path, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	f(r)
+}
diff --git a/talksapp/secret.json b/talksapp/secret.json
deleted file mode 100644
index 979fe98..0000000
--- a/talksapp/secret.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-	"ClientID": "",
-	"ClientSecret": "" 
-}