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