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 legacyRe := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
369 re := regexp.MustCompile(`^(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,255}$`)
370
371 var login string
372
373 validator := func(name string, value string) (complaint string, err error) {
374 if !re.MatchString(value) && !legacyRe.MatchString(value) {
375 return "token has incorrect format", nil
376 }
377 login, err = getLoginFromToken(auth.NewToken(target, value))
378 if err != nil {
379 return fmt.Sprintf("token is invalid: %v", err), nil
380 }
381 return "", nil
382 }
383
384 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
385 if err != nil {
386 return nil, err
387 }
388
389 token := auth.NewToken(target, rawToken)
390 token.SetMetadata(auth.MetaKeyLogin, login)
391
392 return token, nil
393}
394
395func promptURL(repo repository.RepoCommon) (string, string, error) {
396 validRemotes, err := getValidGithubRemoteURLs(repo)
397 if err != nil {
398 return "", "", err
399 }
400
401 validator := func(name, value string) (string, error) {
402 _, _, err := splitURL(value)
403 if err != nil {
404 return err.Error(), nil
405 }
406 return "", nil
407 }
408
409 url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
410 if err != nil {
411 return "", "", err
412 }
413
414 return splitURL(url)
415}
416
417// splitURL extract the owner and project from a github repository URL. It will remove the
418// '.git' extension from the URL before parsing it.
419// Note that Github removes the '.git' extension from projects names at their creation
420func splitURL(url string) (owner string, project string, err error) {
421 cleanURL := strings.TrimSuffix(url, ".git")
422
423 re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
424
425 res := re.FindStringSubmatch(cleanURL)
426 if res == nil {
427 return "", "", ErrBadProjectURL
428 }
429
430 owner = res[1]
431 project = res[2]
432 return
433}
434
435func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
436 remotes, err := repo.GetRemotes()
437 if err != nil {
438 return nil, err
439 }
440
441 urls := make([]string, 0, len(remotes))
442 for _, url := range remotes {
443 // split url can work again with shortURL
444 owner, project, err := splitURL(url)
445 if err == nil {
446 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
447 urls = append(urls, shortURL)
448 }
449 }
450
451 sort.Strings(urls)
452
453 return urls, nil
454}
455
456func promptLogin() (string, error) {
457 var login string
458
459 validator := func(_ string, value string) (string, error) {
460 ok, fixed, err := validateUsername(value)
461 if err != nil {
462 return "", err
463 }
464 if !ok {
465 return "invalid login", nil
466 }
467 login = fixed
468 return "", nil
469 }
470
471 _, err := input.Prompt("Github login", "login", input.Required, validator)
472 if err != nil {
473 return "", err
474 }
475
476 return login, nil
477}
478
479func validateUsername(username string) (bool, string, error) {
480 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
481
482 client := &http.Client{
483 Timeout: defaultTimeout,
484 }
485
486 resp, err := client.Get(url)
487 if err != nil {
488 return false, "", err
489 }
490
491 if resp.StatusCode != http.StatusOK {
492 return false, "", nil
493 }
494
495 data, err := ioutil.ReadAll(resp.Body)
496 if err != nil {
497 return false, "", err
498 }
499
500 err = resp.Body.Close()
501 if err != nil {
502 return false, "", err
503 }
504
505 var decoded struct {
506 Login string `json:"login"`
507 }
508 err = json.Unmarshal(data, &decoded)
509 if err != nil {
510 return false, "", err
511 }
512
513 if decoded.Login == "" {
514 return false, "", fmt.Errorf("validateUsername: missing login in the response")
515 }
516
517 return true, decoded.Login, nil
518}
519
520func validateProject(owner, project string, token *auth.Token) (bool, error) {
521 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
522
523 req, err := http.NewRequest("GET", url, nil)
524 if err != nil {
525 return false, err
526 }
527
528 // need the token for private repositories
529 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
530
531 client := &http.Client{
532 Timeout: defaultTimeout,
533 }
534
535 resp, err := client.Do(req)
536 if err != nil {
537 return false, err
538 }
539
540 err = resp.Body.Close()
541 if err != nil {
542 return false, err
543 }
544
545 return resp.StatusCode == http.StatusOK, nil
546}
547
548func getLoginFromToken(token *auth.Token) (string, error) {
549 ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
550 defer cancel()
551
552 client := buildClient(token)
553
554 var q loginQuery
555
556 err := client.Query(ctx, &q, nil)
557 if err != nil {
558 return "", err
559 }
560 if q.Viewer.Login == "" {
561 return "", fmt.Errorf("github say username is empty")
562 }
563
564 return q.Viewer.Login, nil
565}