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 }