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