github.com/friedemannf/reviewdog@v0.14.0/doghouse/appengine/github.go (about)

     1  package main
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/rand"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"io/ioutil"
    12  	"net/http"
    13  	"net/url"
    14  	"strings"
    15  
    16  	"github.com/google/go-github/v37/github"
    17  	"github.com/justinas/nosurf"
    18  	"github.com/vvakame/sdlog/aelog"
    19  	"golang.org/x/oauth2"
    20  
    21  	"github.com/friedemannf/reviewdog/doghouse/server"
    22  	"github.com/friedemannf/reviewdog/doghouse/server/cookieman"
    23  	"github.com/friedemannf/reviewdog/doghouse/server/storage"
    24  )
    25  
    26  type GitHubHandler struct {
    27  	clientID     string
    28  	clientSecret string
    29  
    30  	tokenStore     *cookieman.CookieStore
    31  	redirURLStore  *cookieman.CookieStore // Redirect URL after login.
    32  	authStateStore *cookieman.CookieStore
    33  
    34  	repoTokenStore storage.GitHubRepositoryTokenStore
    35  
    36  	privateKey    []byte
    37  	integrationID int
    38  }
    39  
    40  func NewGitHubHandler(clientID, clientSecret string, c *cookieman.CookieMan, privateKey []byte, integrationID int) *GitHubHandler {
    41  	return &GitHubHandler{
    42  		clientID:       clientID,
    43  		clientSecret:   clientSecret,
    44  		tokenStore:     c.NewCookieStore("github-token", nil),
    45  		redirURLStore:  c.NewCookieStore("github-redirect-url", nil),
    46  		authStateStore: c.NewCookieStore("github-auth-state", nil),
    47  		repoTokenStore: &storage.GitHubRepoTokenDatastore{},
    48  		integrationID:  integrationID,
    49  		privateKey:     privateKey,
    50  	}
    51  }
    52  
    53  type ghTopTmplData struct {
    54  	Title string
    55  	User  tmplUser
    56  
    57  	App struct {
    58  		Name    string
    59  		HTMLURL string
    60  	}
    61  
    62  	Installations []tmplInstallation
    63  }
    64  
    65  type tmplInstallation struct {
    66  	Account        string
    67  	AccountHTMLURL string
    68  	AccountIconURL string
    69  	HTMLURL        string
    70  }
    71  
    72  type ghRepoTmplData struct {
    73  	Title     string
    74  	Token     string
    75  	User      tmplUser
    76  	Repo      tmplRepo
    77  	CSRFToken string
    78  }
    79  
    80  type tmplUser struct {
    81  	Name      string
    82  	IconURL   string
    83  	GitHubURL string
    84  }
    85  
    86  type tmplRepo struct {
    87  	Owner     string
    88  	Name      string
    89  	GitHubURL string
    90  }
    91  
    92  func (g *GitHubHandler) buildGithubAuthURL(r *http.Request, state string) string {
    93  	redirURL := *r.URL
    94  	redirURL.Path = "/gh/_auth/callback"
    95  	redirURL.RawQuery = ""
    96  	redirURL.Fragment = ""
    97  	const baseURL = "https://github.com/login/oauth/authorize"
    98  	authURL := fmt.Sprintf("%s?client_id=%s&redirect_url=%s&state=%s",
    99  		baseURL, g.clientID, redirURL.RequestURI(), state)
   100  	return authURL
   101  }
   102  
   103  func (g *GitHubHandler) HandleAuthCallback(w http.ResponseWriter, r *http.Request) {
   104  	ctx := r.Context()
   105  	code, state := r.FormValue("code"), r.FormValue("state")
   106  	if code == "" || state == "" {
   107  		w.WriteHeader(http.StatusBadRequest)
   108  		fmt.Fprintln(w, "code and state param is empty")
   109  		return
   110  	}
   111  
   112  	// Verify state.
   113  	cookieState, err := g.authStateStore.Get(r)
   114  	if err != nil || state != string(cookieState) {
   115  		w.WriteHeader(http.StatusBadRequest)
   116  		fmt.Fprintln(w, "state is invalid")
   117  		return
   118  	}
   119  	g.authStateStore.Clear(w)
   120  
   121  	// Request and save access token.
   122  	token, err := g.requestAccessToken(ctx, code, state)
   123  	if err != nil {
   124  		aelog.Errorf(ctx, "failed to get access token: %v", err)
   125  		w.WriteHeader(http.StatusBadRequest)
   126  		fmt.Fprintln(w, "failed to get GitHub access token")
   127  		return
   128  	}
   129  	g.tokenStore.Set(w, []byte(token))
   130  
   131  	// Redirect.
   132  	redirURL := "/gh/"
   133  	if r, err := g.redirURLStore.Get(r); err == nil {
   134  		redirURL = string(r)
   135  		g.redirURLStore.Clear(w)
   136  	}
   137  	http.Redirect(w, r, redirURL, http.StatusFound)
   138  }
   139  
   140  func (g *GitHubHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
   141  	g.tokenStore.Clear(w)
   142  	http.Redirect(w, r, "/", http.StatusFound)
   143  }
   144  
   145  func (g *GitHubHandler) LogInHandler(h http.Handler) http.Handler {
   146  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   147  		if g.isLoggedIn(r) {
   148  			h.ServeHTTP(w, r)
   149  			return
   150  		}
   151  		// Not logged in yet.
   152  		aelog.Debugf(r.Context(), "Not logged in yet.")
   153  		state := securerandom(16)
   154  		g.redirURLStore.Set(w, []byte(r.URL.RequestURI()))
   155  		g.authStateStore.Set(w, []byte(state))
   156  		http.Redirect(w, r, g.buildGithubAuthURL(r, state), http.StatusFound)
   157  	})
   158  }
   159  
   160  func (g *GitHubHandler) isLoggedIn(r *http.Request) bool {
   161  	ok, _ := g.token(r)
   162  	return ok
   163  }
   164  
   165  func securerandom(n int) string {
   166  	b := make([]byte, n)
   167  	io.ReadFull(rand.Reader, b)
   168  	return fmt.Sprintf("%x", b)
   169  }
   170  
   171  // https://developer.github.com/apps/building-github-apps/identifying-and-authorizing-users-for-github-apps/#2-users-are-redirected-back-to-your-site-by-github
   172  // POST https://github.com/login/oauth/access_token
   173  func (g *GitHubHandler) requestAccessToken(ctx context.Context, code, state string) (string, error) {
   174  	const u = "https://github.com/login/oauth/access_token"
   175  	cli := &http.Client{}
   176  	data := url.Values{}
   177  	data.Set("client_id", g.clientID)
   178  	data.Set("client_secret", g.clientSecret)
   179  	data.Set("code", code)
   180  	data.Set("state", state)
   181  
   182  	req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(data.Encode()))
   183  	if err != nil {
   184  		return "", fmt.Errorf("failed to create request: %w", err)
   185  	}
   186  	req = req.WithContext(ctx)
   187  	req.Header.Add("Accept", "application/json")
   188  	req.Header.Add("Accept", "application/vnd.github.machine-man-preview+json")
   189  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   190  
   191  	res, err := cli.Do(req)
   192  	if err != nil {
   193  		return "", fmt.Errorf("failed to request access token: %w", err)
   194  	}
   195  	defer res.Body.Close()
   196  
   197  	b, _ := ioutil.ReadAll(res.Body)
   198  
   199  	var token struct {
   200  		AccessToken string `json:"access_token"`
   201  	}
   202  	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&token); err != nil {
   203  		return "", fmt.Errorf("failed to decode response: %w", err)
   204  	}
   205  
   206  	if token.AccessToken == "" {
   207  		aelog.Errorf(ctx, "response doesn't contain token (response: %s)", b)
   208  		return "", errors.New("response doesn't contain GitHub access token")
   209  	}
   210  
   211  	return token.AccessToken, nil
   212  }
   213  
   214  func (g *GitHubHandler) token(r *http.Request) (bool, string) {
   215  	b, err := g.tokenStore.Get(r)
   216  	if err != nil {
   217  		return false, ""
   218  	}
   219  	return true, string(b)
   220  }
   221  
   222  func (g *GitHubHandler) HandleGitHubTop(w http.ResponseWriter, r *http.Request) {
   223  	ctx := r.Context()
   224  
   225  	ok, token := g.token(r)
   226  	if !ok {
   227  		w.WriteHeader(http.StatusUnauthorized)
   228  		return
   229  	}
   230  
   231  	ts := oauth2.StaticTokenSource(
   232  		&oauth2.Token{AccessToken: token},
   233  	)
   234  	ghcli := github.NewClient(NewAuthClient(ctx, http.DefaultTransport, ts))
   235  
   236  	// /gh/{owner}/{repo}
   237  	paths := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
   238  	switch len(paths) {
   239  	case 1:
   240  		g.handleTop(ctx, ghcli, w)
   241  	case 3:
   242  		g.handleRepo(ctx, ghcli, w, r, paths[1], paths[2])
   243  	default:
   244  		notfound(w)
   245  	}
   246  }
   247  
   248  func notfound(w http.ResponseWriter) {
   249  	w.WriteHeader(http.StatusNotFound)
   250  	fmt.Fprintln(w, "404 Not Found")
   251  }
   252  
   253  func (g *GitHubHandler) getUserOrBadRequest(ctx context.Context, ghcli *github.Client, w http.ResponseWriter) (bool, *github.User) {
   254  	u, _, err := ghcli.Users.Get(ctx, "")
   255  	if err != nil {
   256  		// Token seems invalid. Clear it before returning BadRequest status.
   257  		g.tokenStore.Clear(w)
   258  		w.WriteHeader(http.StatusBadRequest)
   259  		fmt.Fprintf(w, "Cannot get GitHub authenticated user. Please reload the page again.")
   260  		return false, nil
   261  	}
   262  	return true, u
   263  }
   264  
   265  func (g *GitHubHandler) handleTop(ctx context.Context, ghcli *github.Client, w http.ResponseWriter) {
   266  	ok, u := g.getUserOrBadRequest(ctx, ghcli, w)
   267  	if !ok {
   268  		return
   269  	}
   270  
   271  	data := &ghTopTmplData{
   272  		Title: "GitHub - reviewdog",
   273  		User: tmplUser{
   274  			Name:      u.GetName(),
   275  			IconURL:   u.GetAvatarURL(),
   276  			GitHubURL: u.GetHTMLURL(),
   277  		},
   278  	}
   279  
   280  	ghAppCli, err := server.NewGitHubClient(ctx, &server.NewGitHubClientOption{
   281  		Client:        &http.Client{},
   282  		IntegrationID: g.integrationID,
   283  		PrivateKey:    g.privateKey,
   284  	})
   285  	if err != nil {
   286  		w.WriteHeader(http.StatusInternalServerError)
   287  		fmt.Fprintln(w, err)
   288  		return
   289  	}
   290  	app, _, err := ghAppCli.Apps.Get(ctx, "")
   291  	if err != nil {
   292  		w.WriteHeader(http.StatusInternalServerError)
   293  		fmt.Fprintln(w, err)
   294  		return
   295  	}
   296  	data.App.Name = app.GetName()
   297  	data.App.HTMLURL = app.GetHTMLURL()
   298  
   299  	installations, _, err := ghcli.Apps.ListUserInstallations(ctx, nil)
   300  	if err != nil {
   301  		w.WriteHeader(http.StatusInternalServerError)
   302  		fmt.Fprintln(w, err)
   303  		return
   304  	}
   305  	for _, inst := range installations {
   306  		data.Installations = append(data.Installations, tmplInstallation{
   307  			Account:        inst.GetAccount().GetLogin(),
   308  			AccountHTMLURL: inst.GetAccount().GetHTMLURL(),
   309  			AccountIconURL: inst.GetAccount().GetAvatarURL(),
   310  			HTMLURL:        inst.GetHTMLURL(),
   311  		})
   312  	}
   313  
   314  	ghTopTmpl.ExecuteTemplate(w, "base", data)
   315  }
   316  
   317  func (g *GitHubHandler) handleRepo(ctx context.Context, ghcli *github.Client, w http.ResponseWriter, r *http.Request, owner, repoName string) {
   318  	repo, _, err := ghcli.Repositories.Get(ctx, owner, repoName)
   319  	if err != nil {
   320  		if err, ok := err.(*github.ErrorResponse); ok {
   321  			if err.Response.StatusCode == http.StatusNotFound {
   322  				notfound(w)
   323  				return
   324  			}
   325  		}
   326  		w.WriteHeader(http.StatusBadRequest)
   327  		fmt.Fprintf(w, "failed to get repo: %#v", err)
   328  		return
   329  	}
   330  
   331  	if !repo.GetPermissions()["push"] {
   332  		w.WriteHeader(http.StatusUnauthorized)
   333  		fmt.Fprintf(w, "You don't have write permission for %s.", repo.GetHTMLURL())
   334  		return
   335  	}
   336  
   337  	ok, u := g.getUserOrBadRequest(ctx, ghcli, w)
   338  	if !ok {
   339  		return
   340  	}
   341  
   342  	// Regenerate Token.
   343  	if r.Method == http.MethodPost {
   344  		if _, err := server.RegenerateRepoToken(ctx, g.repoTokenStore, repo.Owner.GetLogin(), repo.GetName(), repo.GetID()); err != nil {
   345  			w.WriteHeader(http.StatusInternalServerError)
   346  			fmt.Fprintf(w, "failed to update repository token: %v", err)
   347  			return
   348  		}
   349  		http.Redirect(w, r, r.URL.String(), http.StatusFound)
   350  	}
   351  
   352  	repoToken, err := server.GetOrGenerateRepoToken(ctx, g.repoTokenStore, repo.Owner.GetLogin(), repo.GetName(), repo.GetID())
   353  	if err != nil {
   354  		w.WriteHeader(http.StatusInternalServerError)
   355  		fmt.Fprintf(w, "failed to get repository token for %s.", repo.GetHTMLURL())
   356  		return
   357  	}
   358  
   359  	ghRepoTmpl.ExecuteTemplate(w, "base", &ghRepoTmplData{
   360  		Title: fmt.Sprintf("%s/%s - reviewdog", repo.Owner.GetLogin(), repo.GetName()),
   361  		Token: repoToken,
   362  		User: tmplUser{
   363  			Name:      u.GetName(),
   364  			IconURL:   u.GetAvatarURL(),
   365  			GitHubURL: u.GetHTMLURL(),
   366  		},
   367  		Repo: tmplRepo{
   368  			Owner:     repo.Owner.GetLogin(),
   369  			Name:      repo.GetName(),
   370  			GitHubURL: repo.GetHTMLURL(),
   371  		},
   372  		CSRFToken: nosurf.Token(r),
   373  	})
   374  }
   375  
   376  func NewAuthClient(ctx context.Context, base http.RoundTripper, token oauth2.TokenSource) *http.Client {
   377  	tc := oauth2.NewClient(ctx, token)
   378  	tr := tc.Transport.(*oauth2.Transport)
   379  	tr.Base = base
   380  	return tc
   381  }