1package github
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io/ioutil"
8 "net/http"
9 "net/url"
10 "regexp"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/pkg/errors"
17
18 "github.com/MichaelMure/git-bug/bridge/core"
19 "github.com/MichaelMure/git-bug/bridge/core/auth"
20 "github.com/MichaelMure/git-bug/cache"
21 "github.com/MichaelMure/git-bug/commands/input"
22 "github.com/MichaelMure/git-bug/repository"
23)
24
25const githubClientID = "ce3600aa56c2e69f18a5" // git-bug org
26
27var (
28 ErrBadProjectURL = errors.New("bad project url")
29)
30
31func (g *Github) ValidParams() map[string]interface{} {
32 return map[string]interface{}{
33 "URL": nil,
34 "Login": nil,
35 "CredPrefix": nil,
36 "TokenRaw": nil,
37 "Owner": nil,
38 "Project": nil,
39 }
40}
41
42func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams, interactive bool) (core.Configuration, error) {
43 var err error
44 var owner string
45 var project string
46 var ok bool
47
48 // getting owner and project name
49 switch {
50 case params.Owner != "" && params.Project != "":
51 // first try to use params if both or project and owner are provided
52 owner = params.Owner
53 project = params.Project
54 case params.URL != "":
55 // try to parse params URL and extract owner and project
56 owner, project, err = splitURL(params.URL)
57 if err != nil {
58 return nil, err
59 }
60 default:
61 // terminal prompt
62 if !interactive {
63 return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the remote repository with --owner and --project, or via --url option.")
64 }
65 owner, project, err = promptURL(repo)
66 if err != nil {
67 return nil, err
68 }
69 }
70
71 // validate project owner and override with the correct case
72 ok, owner, err = validateUsername(owner)
73 if err != nil {
74 return nil, err
75 }
76 if !ok {
77 return nil, fmt.Errorf("invalid parameter owner: %v", owner)
78 }
79
80 var login string
81 var cred auth.Credential
82
83 switch {
84 case params.CredPrefix != "":
85 cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
86 if err != nil {
87 return nil, err
88 }
89 l, ok := cred.GetMetadata(auth.MetaKeyLogin)
90 if !ok {
91 return nil, fmt.Errorf("credential doesn't have a login")
92 }
93 login = l
94 case params.TokenRaw != "":
95 token := auth.NewToken(target, params.TokenRaw)
96 login, err = getLoginFromToken(token)
97 if err != nil {
98 return nil, err
99 }
100 token.SetMetadata(auth.MetaKeyLogin, login)
101 cred = token
102 default:
103 if params.Login == "" {
104 if !interactive {
105 return nil, fmt.Errorf("Non-interactive-mode is active. Please specify a login via the --login option.")
106 }
107 login, err = promptLogin()
108 } else {
109 // validate login and override with the correct case
110 ok, login, err = validateUsername(params.Login)
111 if !ok {
112 return nil, fmt.Errorf("invalid parameter login: %v", params.Login)
113 }
114 }
115 if err != nil {
116 return nil, err
117 }
118
119 if !interactive {
120 return nil, fmt.Errorf("Non-interactive-mode is active. Please specify a access token via the --token option.")
121 }
122 cred, err = promptTokenOptions(repo, login, owner, project)
123 if err != nil {
124 return nil, err
125 }
126 }
127
128 token, ok := cred.(*auth.Token)
129 if !ok {
130 return nil, fmt.Errorf("the Github bridge only handle token credentials")
131 }
132
133 // verify access to the repository with token
134 ok, err = validateProject(owner, project, token)
135 if err != nil {
136 return nil, err
137 }
138 if !ok {
139 return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope")
140 }
141
142 conf := make(core.Configuration)
143 conf[core.ConfigKeyTarget] = target
144 conf[confKeyOwner] = owner
145 conf[confKeyProject] = project
146 conf[confKeyDefaultLogin] = login
147
148 err = g.ValidateConfig(conf)
149 if err != nil {
150 return nil, err
151 }
152
153 // don't forget to store the now known valid token
154 if !auth.IdExist(repo, cred.ID()) {
155 err = auth.Store(repo, cred)
156 if err != nil {
157 return nil, err
158 }
159 }
160
161 return conf, core.FinishConfig(repo, metaKeyGithubLogin, login)
162}
163
164func (*Github) ValidateConfig(conf core.Configuration) error {
165 if v, ok := conf[core.ConfigKeyTarget]; !ok {
166 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
167 } else if v != target {
168 return fmt.Errorf("unexpected target name: %v", v)
169 }
170 if _, ok := conf[confKeyOwner]; !ok {
171 return fmt.Errorf("missing %s key", confKeyOwner)
172 }
173 if _, ok := conf[confKeyProject]; !ok {
174 return fmt.Errorf("missing %s key", confKeyProject)
175 }
176 if _, ok := conf[confKeyDefaultLogin]; !ok {
177 return fmt.Errorf("missing %s key", confKeyDefaultLogin)
178 }
179
180 return nil
181}
182
183func requestToken() (string, error) {
184 scope, err := promptUserForProjectVisibility()
185 if err != nil {
186 return "", errors.WithStack(err)
187 }
188 resp, err := requestUserVerificationCode(scope)
189 if err != nil {
190 return "", err
191 }
192 promptUserToGoToBrowser(resp.uri, resp.userCode)
193 return pollGithubForAuthorization(resp.deviceCode, resp.interval)
194}
195
196func promptUserForProjectVisibility() (string, error) {
197 fmt.Println("git-bug will now generate an access token in your Github profile. The token is stored in the global git config.")
198 fmt.Println()
199 fmt.Println("The access scope depend on the type of repository.")
200 fmt.Println("Public:")
201 fmt.Println(" - 'public_repo': to be able to read public repositories")
202 fmt.Println("Private:")
203 fmt.Println(" - 'repo' : to be able to read private repositories")
204 fmt.Println()
205 index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
206 if err != nil {
207 return "", err
208 }
209 return []string{"public_repo", "repo"}[index], nil
210}
211
212type githRespT struct {
213 uri string
214 userCode string
215 deviceCode string
216 interval int64
217}
218
219func requestUserVerificationCode(scope string) (*githRespT, error) {
220 params := url.Values{}
221 params.Set("client_id", githubClientID)
222 params.Set("scope", scope)
223 client := &http.Client{
224 Timeout: defaultTimeout,
225 }
226
227 resp, err := client.PostForm("https://github.com/login/device/code", params)
228 if err != nil {
229 return nil, errors.Wrap(err, "error requesting user verification code")
230 }
231 defer resp.Body.Close()
232 if resp.StatusCode != http.StatusOK {
233 return nil, fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
234 }
235
236 data, err := ioutil.ReadAll(resp.Body)
237 if err != nil {
238 return nil, errors.Wrap(err, "error requesting user verification code")
239 }
240
241 vals, err := url.ParseQuery(string(data))
242 if err != nil {
243 return nil, errors.Wrap(err, "error decoding Github API response")
244 }
245
246 interval, err := strconv.ParseInt(vals.Get("interval"), 10, 64) // base 10, bitSize 64
247 if err != nil {
248 return nil, errors.Wrap(err, "Error parsing integer received from Github API")
249 }
250
251 return &githRespT{
252 uri: vals.Get("verification_uri"),
253 userCode: vals.Get("user_code"),
254 deviceCode: vals.Get("device_code"),
255 interval: interval,
256 }, nil
257}
258
259func promptUserToGoToBrowser(url, userCode string) {
260 fmt.Println("Please visit the following Github URL in a browser and enter your user authentication code.")
261 fmt.Println()
262 fmt.Println(" URL:", url)
263 fmt.Println(" user authentication code:", userCode)
264 fmt.Println()
265}
266
267func pollGithubForAuthorization(deviceCode string, intervalSec int64) (string, error) {
268 params := url.Values{}
269 params.Set("client_id", githubClientID)
270 params.Set("device_code", deviceCode)
271 params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628
272 client := &http.Client{
273 Timeout: defaultTimeout,
274 }
275 interval := time.Duration(intervalSec * 1100) // milliseconds, add 10% margin
276
277 for {
278 resp, err := client.PostForm("https://github.com/login/oauth/access_token", params)
279 if err != nil {
280 fmt.Printf("error polling the Github API: %s\n", err)
281 time.Sleep(interval * time.Millisecond)
282 continue
283 }
284 if resp.StatusCode != http.StatusOK {
285 _ = resp.Body.Close()
286 return "", fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
287 }
288
289 data, err := ioutil.ReadAll(resp.Body)
290 if err != nil {
291 _ = resp.Body.Close()
292 fmt.Printf("error polling the Github API: %s\n", err)
293 time.Sleep(interval * time.Millisecond)
294 continue
295 }
296 _ = resp.Body.Close()
297
298 values, err := url.ParseQuery(string(data))
299 if err != nil {
300 fmt.Printf("error decoding Github API response: %s\n", err)
301 time.Sleep(interval * time.Millisecond)
302 continue
303 }
304
305 if token := values.Get("access_token"); token != "" {
306 return token, nil
307 }
308
309 switch apiError := values.Get("error"); apiError {
310 case "slow_down":
311 interval += 5500 // add 5 seconds (RFC 8628), plus some margin
312 time.Sleep(interval * time.Millisecond)
313 continue
314 case "authorization_pending":
315 time.Sleep(interval * time.Millisecond)
316 continue
317 case "":
318 return "", errors.New("unexpected response from Github API")
319 default:
320 // apiError should equal one of: "expired_token", "unsupported_grant_type",
321 // "incorrect_client_credentials", "incorrect_device_code", or "access_denied"
322 return "", fmt.Errorf("error creating token: %v, %v", apiError, values.Get("error_description"))
323 }
324 }
325}
326
327func promptTokenOptions(repo repository.RepoKeyring, login, owner, project string) (auth.Credential, error) {
328 creds, err := auth.List(repo,
329 auth.WithTarget(target),
330 auth.WithKind(auth.KindToken),
331 auth.WithMeta(auth.MetaKeyLogin, login),
332 )
333 if err != nil {
334 return nil, err
335 }
336
337 cred, index, err := input.PromptCredential(target, "token", creds, []string{
338 "enter my token",
339 "interactive token creation",
340 })
341 switch {
342 case err != nil:
343 return nil, err
344 case cred != nil:
345 return cred, nil
346 case index == 0:
347 return promptToken()
348 case index == 1:
349 value, err := requestToken()
350 if err != nil {
351 return nil, err
352 }
353 token := auth.NewToken(target, value)
354 token.SetMetadata(auth.MetaKeyLogin, login)
355 return token, nil
356 default:
357 panic("missed case")
358 }
359}
360
361func promptToken() (*auth.Token, error) {
362 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
363 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
364 fmt.Println()
365 fmt.Println("The access scope depend on the type of repository.")
366 fmt.Println("Public:")
367 fmt.Println(" - 'public_repo': to be able to read public repositories")
368 fmt.Println("Private:")
369 fmt.Println(" - 'repo' : to be able to read private repositories")
370 fmt.Println()
371
372 legacyRe := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
373 re := regexp.MustCompile(`^(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,255}$`)
374
375 var login string
376
377 validator := func(name string, value string) (complaint string, err error) {
378 if !re.MatchString(value) && !legacyRe.MatchString(value) {
379 return "token has incorrect format", nil
380 }
381 login, err = getLoginFromToken(auth.NewToken(target, value))
382 if err != nil {
383 return fmt.Sprintf("token is invalid: %v", err), nil
384 }
385 return "", nil
386 }
387
388 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
389 if err != nil {
390 return nil, err
391 }
392
393 token := auth.NewToken(target, rawToken)
394 token.SetMetadata(auth.MetaKeyLogin, login)
395
396 return token, nil
397}
398
399func promptURL(repo repository.RepoCommon) (string, string, error) {
400 validRemotes, err := getValidGithubRemoteURLs(repo)
401 if err != nil {
402 return "", "", err
403 }
404
405 validator := func(name, value string) (string, error) {
406 _, _, err := splitURL(value)
407 if err != nil {
408 return err.Error(), nil
409 }
410 return "", nil
411 }
412
413 url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
414 if err != nil {
415 return "", "", err
416 }
417
418 return splitURL(url)
419}
420
421// splitURL extract the owner and project from a github repository URL. It will remove the
422// '.git' extension from the URL before parsing it.
423// Note that Github removes the '.git' extension from projects names at their creation
424func splitURL(url string) (owner string, project string, err error) {
425 cleanURL := strings.TrimSuffix(url, ".git")
426
427 re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
428
429 res := re.FindStringSubmatch(cleanURL)
430 if res == nil {
431 return "", "", ErrBadProjectURL
432 }
433
434 owner = res[1]
435 project = res[2]
436 return
437}
438
439func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
440 remotes, err := repo.GetRemotes()
441 if err != nil {
442 return nil, err
443 }
444
445 urls := make([]string, 0, len(remotes))
446 for _, url := range remotes {
447 // split url can work again with shortURL
448 owner, project, err := splitURL(url)
449 if err == nil {
450 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
451 urls = append(urls, shortURL)
452 }
453 }
454
455 sort.Strings(urls)
456
457 return urls, nil
458}
459
460func promptLogin() (string, error) {
461 var login string
462
463 validator := func(_ string, value string) (string, error) {
464 ok, fixed, err := validateUsername(value)
465 if err != nil {
466 return "", err
467 }
468 if !ok {
469 return "invalid login", nil
470 }
471 login = fixed
472 return "", nil
473 }
474
475 _, err := input.Prompt("Github login", "login", input.Required, validator)
476 if err != nil {
477 return "", err
478 }
479
480 return login, nil
481}
482
483func validateUsername(username string) (bool, string, error) {
484 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
485
486 client := &http.Client{
487 Timeout: defaultTimeout,
488 }
489
490 resp, err := client.Get(url)
491 if err != nil {
492 return false, "", err
493 }
494
495 if resp.StatusCode != http.StatusOK {
496 return false, "", nil
497 }
498
499 data, err := ioutil.ReadAll(resp.Body)
500 if err != nil {
501 return false, "", err
502 }
503
504 err = resp.Body.Close()
505 if err != nil {
506 return false, "", err
507 }
508
509 var decoded struct {
510 Login string `json:"login"`
511 }
512 err = json.Unmarshal(data, &decoded)
513 if err != nil {
514 return false, "", err
515 }
516
517 if decoded.Login == "" {
518 return false, "", fmt.Errorf("validateUsername: missing login in the response")
519 }
520
521 return true, decoded.Login, nil
522}
523
524func validateProject(owner, project string, token *auth.Token) (bool, error) {
525 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
526
527 req, err := http.NewRequest("GET", url, nil)
528 if err != nil {
529 return false, err
530 }
531
532 // need the token for private repositories
533 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
534
535 client := &http.Client{
536 Timeout: defaultTimeout,
537 }
538
539 resp, err := client.Do(req)
540 if err != nil {
541 return false, err
542 }
543
544 err = resp.Body.Close()
545 if err != nil {
546 return false, err
547 }
548
549 return resp.StatusCode == http.StatusOK, nil
550}
551
552func getLoginFromToken(token *auth.Token) (string, error) {
553 ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
554 defer cancel()
555
556 client := buildClient(token)
557
558 var q loginQuery
559
560 err := client.queryPrintMsgs(ctx, &q, nil)
561 if err != nil {
562 return "", err
563 }
564 if q.Viewer.Login == "" {
565 return "", fmt.Errorf("github say username is empty")
566 }
567
568 return q.Viewer.Login, nil
569}