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