|  | // Copyright 2017 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 main | 
|  |  | 
|  | import ( | 
|  | "compress/gzip" | 
|  | "context" | 
|  | "fmt" | 
|  | "html/template" | 
|  | "log" | 
|  | "math/rand" | 
|  | "net/http" | 
|  | "path" | 
|  | "strconv" | 
|  | "strings" | 
|  | "sync" | 
|  | "time" | 
|  |  | 
|  | "golang.org/x/build/devapp/owners" | 
|  | "golang.org/x/build/maintner" | 
|  | "golang.org/x/build/maintner/godata" | 
|  | ) | 
|  |  | 
|  | // A server is an http.Handler that serves content within staticDir at root and | 
|  | // the dynamically-generated dashboards at their respective endpoints. | 
|  | type server struct { | 
|  | mux         *http.ServeMux | 
|  | staticDir   string | 
|  | templateDir string | 
|  | reloadTmpls bool | 
|  |  | 
|  | cMu              sync.RWMutex // Used to protect the fields below. | 
|  | corpus           *maintner.Corpus | 
|  | repo             *maintner.GitHubRepo    // The golang/go repo. | 
|  | proj             *maintner.GerritProject // The go.googlesource.com/go project. | 
|  | helpWantedIssues []issueData | 
|  | data             pageData | 
|  |  | 
|  | // GopherCon-specific fields. Must still hold cMu when reading/writing these. | 
|  | userMapping map[int]*maintner.GitHubUser // Gerrit Owner ID => GitHub user | 
|  | activities  []activity                   // All contribution activities | 
|  | totalPoints int | 
|  | } | 
|  |  | 
|  | type issueData struct { | 
|  | id          int32 | 
|  | titlePrefix string | 
|  | } | 
|  |  | 
|  | type pageData struct { | 
|  | release releaseData | 
|  | reviews reviewsData | 
|  | stats   statsData | 
|  | } | 
|  |  | 
|  | func newServer(mux *http.ServeMux, staticDir, templateDir string, reloadTmpls bool) *server { | 
|  | s := &server{ | 
|  | mux:         mux, | 
|  | staticDir:   staticDir, | 
|  | templateDir: templateDir, | 
|  | reloadTmpls: reloadTmpls, | 
|  | userMapping: map[int]*maintner.GitHubUser{}, | 
|  | } | 
|  | s.mux.Handle("/", http.FileServer(http.Dir(s.staticDir))) | 
|  | s.mux.HandleFunc("/favicon.ico", s.handleFavicon) | 
|  | s.mux.HandleFunc("/release", s.withTemplate("/release.tmpl", s.handleRelease)) | 
|  | s.mux.HandleFunc("/reviews", s.withTemplate("/reviews.tmpl", s.handleReviews)) | 
|  | s.mux.HandleFunc("/stats", s.withTemplate("/stats.tmpl", s.handleStats)) | 
|  | s.mux.HandleFunc("/dir/", handleDirRedirect) | 
|  | s.mux.HandleFunc("/owners", owners.Handler) | 
|  | s.mux.Handle("/owners/", http.RedirectHandler("/owners", http.StatusPermanentRedirect)) // TODO: remove after clients updated to use URL without trailing slash | 
|  | for _, p := range []string{"/imfeelinghelpful", "/imfeelinglucky"} { | 
|  | s.mux.HandleFunc(p, s.handleRandomHelpWantedIssue) | 
|  | } | 
|  | s.mux.HandleFunc("/_/activities", s.handleActivities) | 
|  | return s | 
|  | } | 
|  |  | 
|  | func (s *server) withTemplate(tmpl string, fn func(*template.Template, http.ResponseWriter, *http.Request)) http.HandlerFunc { | 
|  | t := template.Must(template.ParseFiles(path.Join(s.templateDir, tmpl))) | 
|  | return func(w http.ResponseWriter, r *http.Request) { | 
|  | if s.reloadTmpls { | 
|  | t = template.Must(template.ParseFiles(path.Join(s.templateDir, tmpl))) | 
|  | } | 
|  | fn(t, w, r) | 
|  | } | 
|  | } | 
|  |  | 
|  | // initCorpus fetches a full maintner corpus, overwriting any existing data. | 
|  | func (s *server) initCorpus(ctx context.Context) error { | 
|  | s.cMu.Lock() | 
|  | defer s.cMu.Unlock() | 
|  | corpus, err := godata.Get(ctx) | 
|  | if err != nil { | 
|  | return fmt.Errorf("godata.Get: %v", err) | 
|  | } | 
|  | s.corpus = corpus | 
|  | s.repo = s.corpus.GitHub().Repo("golang", "go") | 
|  | if s.repo == nil { | 
|  | return fmt.Errorf(`s.corpus.GitHub().Repo("golang", "go") = nil`) | 
|  | } | 
|  | s.proj = s.corpus.Gerrit().Project("go.googlesource.com", "go") | 
|  | if s.proj == nil { | 
|  | return fmt.Errorf(`s.corpus.Gerrit().Project("go.googlesource.com", "go") = nil`) | 
|  | } | 
|  | return nil | 
|  | } | 
|  |  | 
|  | // corpusUpdateLoop continuously updates the server’s corpus until ctx’s Done | 
|  | // channel is closed. | 
|  | func (s *server) corpusUpdateLoop(ctx context.Context) { | 
|  | log.Println("Starting corpus update loop ...") | 
|  | for { | 
|  | log.Println("Updating help wanted issues ...") | 
|  | s.updateHelpWantedIssues() | 
|  | log.Println("Updating activities ...") | 
|  | s.updateActivities() | 
|  | s.cMu.Lock() | 
|  | s.data.release.dirty = true | 
|  | s.data.reviews.dirty = true | 
|  | s.data.stats.dirty = true | 
|  | s.cMu.Unlock() | 
|  | err := s.corpus.UpdateWithLocker(ctx, &s.cMu) | 
|  | if err != nil { | 
|  | if err == maintner.ErrSplit { | 
|  | log.Println("Corpus out of sync. Re-fetching corpus.") | 
|  | s.initCorpus(ctx) | 
|  | } else { | 
|  | log.Printf("corpus.Update: %v; sleeping 15s", err) | 
|  | time.Sleep(15 * time.Second) | 
|  | continue | 
|  | } | 
|  | } | 
|  |  | 
|  | select { | 
|  | case <-ctx.Done(): | 
|  | return | 
|  | default: | 
|  | continue | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | const ( | 
|  | issuesURLBase = "https://golang.org/issue/" | 
|  |  | 
|  | labelHelpWantedID = 150880243 | 
|  | ) | 
|  |  | 
|  | func (s *server) updateHelpWantedIssues() { | 
|  | s.cMu.Lock() | 
|  | defer s.cMu.Unlock() | 
|  |  | 
|  | var issues []issueData | 
|  | s.repo.ForeachIssue(func(i *maintner.GitHubIssue) error { | 
|  | if i.Closed { | 
|  | return nil | 
|  | } | 
|  | if i.HasLabelID(labelHelpWantedID) { | 
|  | prefix := strings.SplitN(i.Title, ":", 2)[0] | 
|  | issues = append(issues, issueData{id: i.Number, titlePrefix: prefix}) | 
|  | } | 
|  | return nil | 
|  | }) | 
|  | s.helpWantedIssues = issues | 
|  | } | 
|  |  | 
|  | func (s *server) handleRandomHelpWantedIssue(w http.ResponseWriter, r *http.Request) { | 
|  | s.cMu.RLock() | 
|  | defer s.cMu.RUnlock() | 
|  | if len(s.helpWantedIssues) == 0 { | 
|  | http.Redirect(w, r, issuesURLBase, http.StatusSeeOther) | 
|  | return | 
|  | } | 
|  | pkgs := r.URL.Query().Get("pkg") | 
|  | var rid int32 | 
|  | if pkgs == "" { | 
|  | rid = s.helpWantedIssues[rand.Intn(len(s.helpWantedIssues))].id | 
|  | } else { | 
|  | filtered := s.filteredHelpWantedIssues(strings.Split(pkgs, ",")...) | 
|  | if len(filtered) == 0 { | 
|  | http.Redirect(w, r, issuesURLBase, http.StatusSeeOther) | 
|  | return | 
|  | } | 
|  | rid = filtered[rand.Intn(len(filtered))].id | 
|  | } | 
|  | http.Redirect(w, r, issuesURLBase+strconv.Itoa(int(rid)), http.StatusSeeOther) | 
|  | } | 
|  |  | 
|  | func (s *server) filteredHelpWantedIssues(pkgs ...string) []issueData { | 
|  | var issues []issueData | 
|  | for _, i := range s.helpWantedIssues { | 
|  | for _, p := range pkgs { | 
|  | if strings.HasPrefix(i.titlePrefix, p) { | 
|  | issues = append(issues, i) | 
|  | break | 
|  | } | 
|  | } | 
|  | } | 
|  | return issues | 
|  | } | 
|  |  | 
|  | func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) { | 
|  | // Need to specify content type for consistent tests, without this it's | 
|  | // determined from mime.types on the box the test is running on | 
|  | w.Header().Set("Content-Type", "image/x-icon") | 
|  | http.ServeFile(w, r, path.Join(s.staticDir, "/favicon.ico")) | 
|  | } | 
|  |  | 
|  | // ServeHTTP satisfies the http.Handler interface. | 
|  | func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 
|  | if r.TLS != nil { | 
|  | w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload") | 
|  | } | 
|  | if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { | 
|  | w.Header().Set("Content-Encoding", "gzip") | 
|  | gz := gzip.NewWriter(w) | 
|  | defer gz.Close() | 
|  | gzw := &gzipResponseWriter{Writer: gz, ResponseWriter: w} | 
|  | s.mux.ServeHTTP(gzw, r) | 
|  | return | 
|  | } | 
|  | s.mux.ServeHTTP(w, r) | 
|  | } | 
|  |  | 
|  | // handleDirRedirect accepts requests of the form: | 
|  | // | 
|  | //	/dir/REPO/some/dir/ | 
|  | // | 
|  | // And redirects them to either: | 
|  | // | 
|  | //	https://github.com/golang/REPO/tree/master/some/dir/ | 
|  | // | 
|  | // or: | 
|  | // | 
|  | //	https://go.googlesource.com/REPO/+/master/some/dir/ | 
|  | // | 
|  | // ... depending on the Referer. This is so we can make links | 
|  | // in Markdown docs that are clickable on both GitHub and | 
|  | // in the go.googlesource.com viewer. If detection fails, we | 
|  | // default to GitHub. | 
|  | func handleDirRedirect(w http.ResponseWriter, r *http.Request) { | 
|  | useGoog := strings.Contains(r.Referer(), "googlesource.com") | 
|  | path := r.URL.Path | 
|  | if !strings.HasPrefix(path, "/dir/") { | 
|  | http.Error(w, "bad mux", http.StatusInternalServerError) | 
|  | return | 
|  | } | 
|  | path = strings.TrimPrefix(path, "/dir/") | 
|  | // path is now "REPO/some/dir/" | 
|  | var repo string | 
|  | slash := strings.IndexByte(path, '/') | 
|  | if slash == -1 { | 
|  | repo, path = path, "" | 
|  | } else { | 
|  | repo, path = path[:slash], path[slash+1:] | 
|  | } | 
|  | path = strings.TrimSuffix(path, "/") | 
|  | var target string | 
|  | if useGoog { | 
|  | target = fmt.Sprintf("https://go.googlesource.com/%s/+/master/%s", repo, path) | 
|  | } else { | 
|  | target = fmt.Sprintf("https://github.com/golang/%s/tree/master/%s", repo, path) | 
|  | } | 
|  | http.Redirect(w, r, target, http.StatusFound) | 
|  | } |