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