golang.org/x/build@v0.0.0-20240506185731-218518f32b70/devapp/server.go (about)

     1  // Copyright 2017 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"compress/gzip"
     9  	"context"
    10  	"fmt"
    11  	"html/template"
    12  	"log"
    13  	"math/rand"
    14  	"net/http"
    15  	"path"
    16  	"strconv"
    17  	"strings"
    18  	"sync"
    19  	"time"
    20  
    21  	"golang.org/x/build/devapp/owners"
    22  	"golang.org/x/build/maintner"
    23  	"golang.org/x/build/maintner/godata"
    24  )
    25  
    26  // A server is an http.Handler that serves content within staticDir at root and
    27  // the dynamically-generated dashboards at their respective endpoints.
    28  type server struct {
    29  	mux         *http.ServeMux
    30  	staticDir   string
    31  	templateDir string
    32  	reloadTmpls bool
    33  
    34  	cMu              sync.RWMutex // Used to protect the fields below.
    35  	corpus           *maintner.Corpus
    36  	repo             *maintner.GitHubRepo    // The golang/go repo.
    37  	proj             *maintner.GerritProject // The go.googlesource.com/go project.
    38  	helpWantedIssues []issueData
    39  	data             pageData
    40  
    41  	// GopherCon-specific fields. Must still hold cMu when reading/writing these.
    42  	userMapping map[int]*maintner.GitHubUser // Gerrit Owner ID => GitHub user
    43  	activities  []activity                   // All contribution activities
    44  	totalPoints int
    45  }
    46  
    47  type issueData struct {
    48  	id          int32
    49  	titlePrefix string
    50  }
    51  
    52  type pageData struct {
    53  	release releaseData
    54  	reviews reviewsData
    55  	stats   statsData
    56  }
    57  
    58  func newServer(mux *http.ServeMux, staticDir, templateDir string, reloadTmpls bool) *server {
    59  	s := &server{
    60  		mux:         mux,
    61  		staticDir:   staticDir,
    62  		templateDir: templateDir,
    63  		reloadTmpls: reloadTmpls,
    64  		userMapping: map[int]*maintner.GitHubUser{},
    65  	}
    66  	s.mux.Handle("/", http.FileServer(http.Dir(s.staticDir)))
    67  	s.mux.HandleFunc("/favicon.ico", s.handleFavicon)
    68  	s.mux.HandleFunc("/release", s.withTemplate("/release.tmpl", s.handleRelease))
    69  	s.mux.HandleFunc("/reviews", s.withTemplate("/reviews.tmpl", s.handleReviews))
    70  	s.mux.HandleFunc("/stats", s.withTemplate("/stats.tmpl", s.handleStats))
    71  	s.mux.HandleFunc("/dir/", handleDirRedirect)
    72  	s.mux.HandleFunc("/owners", owners.Handler)
    73  	s.mux.Handle("/owners/", http.RedirectHandler("/owners", http.StatusPermanentRedirect)) // TODO: remove after clients updated to use URL without trailing slash
    74  	for _, p := range []string{"/imfeelinghelpful", "/imfeelinglucky"} {
    75  		s.mux.HandleFunc(p, s.handleRandomHelpWantedIssue)
    76  	}
    77  	s.mux.HandleFunc("/_/activities", s.handleActivities)
    78  	return s
    79  }
    80  
    81  func (s *server) withTemplate(tmpl string, fn func(*template.Template, http.ResponseWriter, *http.Request)) http.HandlerFunc {
    82  	t := template.Must(template.ParseFiles(path.Join(s.templateDir, tmpl)))
    83  	return func(w http.ResponseWriter, r *http.Request) {
    84  		if s.reloadTmpls {
    85  			t = template.Must(template.ParseFiles(path.Join(s.templateDir, tmpl)))
    86  		}
    87  		fn(t, w, r)
    88  	}
    89  }
    90  
    91  // initCorpus fetches a full maintner corpus, overwriting any existing data.
    92  func (s *server) initCorpus(ctx context.Context) error {
    93  	s.cMu.Lock()
    94  	defer s.cMu.Unlock()
    95  	corpus, err := godata.Get(ctx)
    96  	if err != nil {
    97  		return fmt.Errorf("godata.Get: %v", err)
    98  	}
    99  	s.corpus = corpus
   100  	s.repo = s.corpus.GitHub().Repo("golang", "go")
   101  	if s.repo == nil {
   102  		return fmt.Errorf(`s.corpus.GitHub().Repo("golang", "go") = nil`)
   103  	}
   104  	s.proj = s.corpus.Gerrit().Project("go.googlesource.com", "go")
   105  	if s.proj == nil {
   106  		return fmt.Errorf(`s.corpus.Gerrit().Project("go.googlesource.com", "go") = nil`)
   107  	}
   108  	return nil
   109  }
   110  
   111  // corpusUpdateLoop continuously updates the server’s corpus until ctx’s Done
   112  // channel is closed.
   113  func (s *server) corpusUpdateLoop(ctx context.Context) {
   114  	log.Println("Starting corpus update loop ...")
   115  	for {
   116  		log.Println("Updating help wanted issues ...")
   117  		s.updateHelpWantedIssues()
   118  		log.Println("Updating activities ...")
   119  		s.updateActivities()
   120  		s.cMu.Lock()
   121  		s.data.release.dirty = true
   122  		s.data.reviews.dirty = true
   123  		s.data.stats.dirty = true
   124  		s.cMu.Unlock()
   125  		err := s.corpus.UpdateWithLocker(ctx, &s.cMu)
   126  		if err != nil {
   127  			if err == maintner.ErrSplit {
   128  				log.Println("Corpus out of sync. Re-fetching corpus.")
   129  				s.initCorpus(ctx)
   130  			} else {
   131  				log.Printf("corpus.Update: %v; sleeping 15s", err)
   132  				time.Sleep(15 * time.Second)
   133  				continue
   134  			}
   135  		}
   136  
   137  		select {
   138  		case <-ctx.Done():
   139  			return
   140  		default:
   141  			continue
   142  		}
   143  	}
   144  }
   145  
   146  const (
   147  	issuesURLBase = "https://golang.org/issue/"
   148  
   149  	labelHelpWantedID = 150880243
   150  )
   151  
   152  func (s *server) updateHelpWantedIssues() {
   153  	s.cMu.Lock()
   154  	defer s.cMu.Unlock()
   155  
   156  	var issues []issueData
   157  	s.repo.ForeachIssue(func(i *maintner.GitHubIssue) error {
   158  		if i.Closed {
   159  			return nil
   160  		}
   161  		if i.HasLabelID(labelHelpWantedID) {
   162  			prefix := strings.SplitN(i.Title, ":", 2)[0]
   163  			issues = append(issues, issueData{id: i.Number, titlePrefix: prefix})
   164  		}
   165  		return nil
   166  	})
   167  	s.helpWantedIssues = issues
   168  }
   169  
   170  func (s *server) handleRandomHelpWantedIssue(w http.ResponseWriter, r *http.Request) {
   171  	s.cMu.RLock()
   172  	defer s.cMu.RUnlock()
   173  	if len(s.helpWantedIssues) == 0 {
   174  		http.Redirect(w, r, issuesURLBase, http.StatusSeeOther)
   175  		return
   176  	}
   177  	pkgs := r.URL.Query().Get("pkg")
   178  	var rid int32
   179  	if pkgs == "" {
   180  		rid = s.helpWantedIssues[rand.Intn(len(s.helpWantedIssues))].id
   181  	} else {
   182  		filtered := s.filteredHelpWantedIssues(strings.Split(pkgs, ",")...)
   183  		if len(filtered) == 0 {
   184  			http.Redirect(w, r, issuesURLBase, http.StatusSeeOther)
   185  			return
   186  		}
   187  		rid = filtered[rand.Intn(len(filtered))].id
   188  	}
   189  	http.Redirect(w, r, issuesURLBase+strconv.Itoa(int(rid)), http.StatusSeeOther)
   190  }
   191  
   192  func (s *server) filteredHelpWantedIssues(pkgs ...string) []issueData {
   193  	var issues []issueData
   194  	for _, i := range s.helpWantedIssues {
   195  		for _, p := range pkgs {
   196  			if strings.HasPrefix(i.titlePrefix, p) {
   197  				issues = append(issues, i)
   198  				break
   199  			}
   200  		}
   201  	}
   202  	return issues
   203  }
   204  
   205  func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) {
   206  	// Need to specify content type for consistent tests, without this it's
   207  	// determined from mime.types on the box the test is running on
   208  	w.Header().Set("Content-Type", "image/x-icon")
   209  	http.ServeFile(w, r, path.Join(s.staticDir, "/favicon.ico"))
   210  }
   211  
   212  // ServeHTTP satisfies the http.Handler interface.
   213  func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   214  	if r.TLS != nil {
   215  		w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload")
   216  	}
   217  	if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
   218  		w.Header().Set("Content-Encoding", "gzip")
   219  		gz := gzip.NewWriter(w)
   220  		defer gz.Close()
   221  		gzw := &gzipResponseWriter{Writer: gz, ResponseWriter: w}
   222  		s.mux.ServeHTTP(gzw, r)
   223  		return
   224  	}
   225  	s.mux.ServeHTTP(w, r)
   226  }
   227  
   228  // handleDirRedirect accepts requests of the form:
   229  //
   230  //	/dir/REPO/some/dir/
   231  //
   232  // And redirects them to either:
   233  //
   234  //	https://github.com/golang/REPO/tree/master/some/dir/
   235  //
   236  // or:
   237  //
   238  //	https://go.googlesource.com/REPO/+/master/some/dir/
   239  //
   240  // ... depending on the Referer. This is so we can make links
   241  // in Markdown docs that are clickable on both GitHub and
   242  // in the go.googlesource.com viewer. If detection fails, we
   243  // default to GitHub.
   244  func handleDirRedirect(w http.ResponseWriter, r *http.Request) {
   245  	useGoog := strings.Contains(r.Referer(), "googlesource.com")
   246  	path := r.URL.Path
   247  	if !strings.HasPrefix(path, "/dir/") {
   248  		http.Error(w, "bad mux", http.StatusInternalServerError)
   249  		return
   250  	}
   251  	path = strings.TrimPrefix(path, "/dir/")
   252  	// path is now "REPO/some/dir/"
   253  	var repo string
   254  	slash := strings.IndexByte(path, '/')
   255  	if slash == -1 {
   256  		repo, path = path, ""
   257  	} else {
   258  		repo, path = path[:slash], path[slash+1:]
   259  	}
   260  	path = strings.TrimSuffix(path, "/")
   261  	var target string
   262  	if useGoog {
   263  		target = fmt.Sprintf("https://go.googlesource.com/%s/+/master/%s", repo, path)
   264  	} else {
   265  		target = fmt.Sprintf("https://github.com/golang/%s/tree/master/%s", repo, path)
   266  	}
   267  	http.Redirect(w, r, target, http.StatusFound)
   268  }