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