1package github
2
3import (
4 "bufio"
5 "bytes"
6 "encoding/json"
7 "fmt"
8 "io"
9 "io/ioutil"
10 "math/rand"
11 "net/http"
12 "os"
13 "regexp"
14 "sort"
15 "strconv"
16 "strings"
17 "time"
18
19 text "github.com/MichaelMure/go-term-text"
20 "github.com/pkg/errors"
21
22 "github.com/MichaelMure/git-bug/bridge/core"
23 "github.com/MichaelMure/git-bug/bridge/core/auth"
24 "github.com/MichaelMure/git-bug/cache"
25 "github.com/MichaelMure/git-bug/input"
26 "github.com/MichaelMure/git-bug/repository"
27 "github.com/MichaelMure/git-bug/util/colors"
28)
29
30const (
31 target = "github"
32 githubV3Url = "https://api.github.com"
33 keyOwner = "owner"
34 keyProject = "project"
35
36 defaultTimeout = 60 * time.Second
37)
38
39var (
40 ErrBadProjectURL = errors.New("bad project url")
41)
42
43func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
44 if params.BaseURL != "" {
45 fmt.Println("warning: --base-url is ineffective for a Github bridge")
46 }
47
48 conf := make(core.Configuration)
49 var err error
50 var owner string
51 var project string
52
53 // getting owner and project name
54 switch {
55 case params.Owner != "" && params.Project != "":
56 // first try to use params if both or project and owner are provided
57 owner = params.Owner
58 project = params.Project
59 case params.URL != "":
60 // try to parse params URL and extract owner and project
61 owner, project, err = splitURL(params.URL)
62 if err != nil {
63 return nil, err
64 }
65 default:
66 // terminal prompt
67 owner, project, err = promptURL(repo)
68 if err != nil {
69 return nil, err
70 }
71 }
72
73 // validate project owner
74 ok, err := validateUsername(owner)
75 if err != nil {
76 return nil, err
77 }
78 if !ok {
79 return nil, fmt.Errorf("invalid parameter owner: %v", owner)
80 }
81
82 login := params.Login
83 if login == "" {
84 validator := func(name string, value string) (string, error) {
85 ok, err := validateUsername(value)
86 if err != nil {
87 return "", err
88 }
89 if !ok {
90 return "invalid login", nil
91 }
92 return "", nil
93 }
94
95 login, err = input.Prompt("Github login", "login", input.Required, validator)
96 if err != nil {
97 return nil, err
98 }
99 }
100
101 var cred auth.Credential
102
103 switch {
104 case params.CredPrefix != "":
105 cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
106 if err != nil {
107 return nil, err
108 }
109 case params.TokenRaw != "":
110 cred = auth.NewToken(params.TokenRaw, target)
111 cred.SetMetadata(auth.MetaKeyLogin, login)
112 default:
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[core.ConfigKeyTarget] = target
134 conf[keyOwner] = owner
135 conf[keyProject] = project
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
160 if _, ok := conf[keyOwner]; !ok {
161 return fmt.Errorf("missing %s key", keyOwner)
162 }
163
164 if _, ok := conf[keyProject]; !ok {
165 return fmt.Errorf("missing %s key", keyProject)
166 }
167
168 return nil
169}
170
171func requestToken(note, login, password string, scope string) (*http.Response, error) {
172 return requestTokenWith2FA(note, login, password, "", scope)
173}
174
175func requestTokenWith2FA(note, login, password, otpCode string, scope string) (*http.Response, error) {
176 url := fmt.Sprintf("%s/authorizations", githubV3Url)
177 params := struct {
178 Scopes []string `json:"scopes"`
179 Note string `json:"note"`
180 Fingerprint string `json:"fingerprint"`
181 }{
182 Scopes: []string{scope},
183 Note: note,
184 Fingerprint: randomFingerprint(),
185 }
186
187 data, err := json.Marshal(params)
188 if err != nil {
189 return nil, err
190 }
191
192 req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
193 if err != nil {
194 return nil, err
195 }
196
197 req.SetBasicAuth(login, password)
198 req.Header.Set("Content-Type", "application/json")
199
200 if otpCode != "" {
201 req.Header.Set("X-GitHub-OTP", otpCode)
202 }
203
204 client := &http.Client{
205 Timeout: defaultTimeout,
206 }
207
208 return client.Do(req)
209}
210
211func decodeBody(body io.ReadCloser) (string, error) {
212 data, _ := ioutil.ReadAll(body)
213
214 aux := struct {
215 Token string `json:"token"`
216 }{}
217
218 err := json.Unmarshal(data, &aux)
219 if err != nil {
220 return "", err
221 }
222
223 if aux.Token == "" {
224 return "", fmt.Errorf("no token found in response: %s", string(data))
225 }
226
227 return aux.Token, nil
228}
229
230func randomFingerprint() string {
231 // Doesn't have to be crypto secure, it's just to avoid token collision
232 rand.Seed(time.Now().UnixNano())
233 var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
234 b := make([]rune, 32)
235 for i := range b {
236 b[i] = letterRunes[rand.Intn(len(letterRunes))]
237 }
238 return string(b)
239}
240
241func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) {
242 for {
243 creds, err := auth.List(repo, auth.WithTarget(target), auth.WithMeta(auth.MetaKeyLogin, login))
244 if err != nil {
245 return nil, err
246 }
247
248 fmt.Println()
249 fmt.Println("[1]: enter my token")
250 fmt.Println("[2]: interactive token creation")
251
252 if len(creds) > 0 {
253 sort.Sort(auth.ById(creds))
254
255 fmt.Println()
256 fmt.Println("Existing tokens for Github:")
257 for i, cred := range creds {
258 token := cred.(*auth.Token)
259 fmt.Printf("[%d]: %s => %s (login: %s, %s)\n",
260 i+3,
261 colors.Cyan(token.ID().Human()),
262 colors.Red(text.TruncateMax(token.Value, 10)),
263 token.Metadata()[auth.MetaKeyLogin],
264 token.CreateTime().Format(time.RFC822),
265 )
266 }
267 }
268
269 fmt.Println()
270 fmt.Print("Select option: ")
271
272 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
273 fmt.Println()
274 if err != nil {
275 return nil, err
276 }
277
278 line = strings.TrimSpace(line)
279 index, err := strconv.Atoi(line)
280 if err != nil || index < 1 || index > len(creds)+2 {
281 fmt.Println("invalid input")
282 continue
283 }
284
285 switch index {
286 case 1:
287 value, err := promptToken()
288 if err != nil {
289 return nil, err
290 }
291 token := auth.NewToken(value, target)
292 token.SetMetadata(auth.MetaKeyLogin, login)
293 return token, nil
294 case 2:
295 value, err := loginAndRequestToken(login, owner, project)
296 if err != nil {
297 return nil, err
298 }
299 token := auth.NewToken(value, target)
300 token.SetMetadata(auth.MetaKeyLogin, login)
301 return token, nil
302 default:
303 return creds[index-3], nil
304 }
305 }
306}
307
308func promptToken() (string, error) {
309 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
310 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
311 fmt.Println()
312 fmt.Println("The access scope depend on the type of repository.")
313 fmt.Println("Public:")
314 fmt.Println(" - 'public_repo': to be able to read public repositories")
315 fmt.Println("Private:")
316 fmt.Println(" - 'repo' : to be able to read private repositories")
317 fmt.Println()
318
319 re, err := regexp.Compile(`^[a-zA-Z0-9]{40}$`)
320 if err != nil {
321 panic("regexp compile:" + err.Error())
322 }
323
324 validator := func(name string, value string) (complaint string, err error) {
325 if re.MatchString(value) {
326 return "", nil
327 }
328 return "token has incorrect format", nil
329 }
330
331 return input.Prompt("Enter token", "token", input.Required, validator)
332}
333
334func loginAndRequestToken(login, owner, project string) (string, error) {
335 fmt.Println("git-bug will now generate an access token in your Github profile. Your credential are not stored and are only used to generate the token. The token is stored in the global git config.")
336 fmt.Println()
337 fmt.Println("The access scope depend on the type of repository.")
338 fmt.Println("Public:")
339 fmt.Println(" - 'public_repo': to be able to read public repositories")
340 fmt.Println("Private:")
341 fmt.Println(" - 'repo' : to be able to read private repositories")
342 fmt.Println()
343
344 // prompt project visibility to know the token scope needed for the repository
345 i, err := input.PromptChoice("repository visibility", []string{"public", "private"})
346 if err != nil {
347 return "", err
348 }
349 isPublic := i == 0
350
351 password, err := input.PromptPassword("Password", "password", input.Required)
352 if err != nil {
353 return "", err
354 }
355
356 var scope string
357 if isPublic {
358 // public_repo is requested to be able to read public repositories
359 scope = "public_repo"
360 } else {
361 // 'repo' is request to be able to read private repositories
362 // /!\ token will have read/write rights on every private repository you have access to
363 scope = "repo"
364 }
365
366 // Attempt to authenticate and create a token
367
368 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
369
370 resp, err := requestToken(note, login, password, scope)
371 if err != nil {
372 return "", err
373 }
374
375 defer resp.Body.Close()
376
377 // Handle 2FA is needed
378 OTPHeader := resp.Header.Get("X-GitHub-OTP")
379 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
380 otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
381 if err != nil {
382 return "", err
383 }
384
385 resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
386 if err != nil {
387 return "", err
388 }
389
390 defer resp.Body.Close()
391 }
392
393 if resp.StatusCode == http.StatusCreated {
394 return decodeBody(resp.Body)
395 }
396
397 b, _ := ioutil.ReadAll(resp.Body)
398 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
399}
400
401func promptURL(repo repository.RepoCommon) (string, string, error) {
402 // remote suggestions
403 remotes, err := repo.GetRemotes()
404 if err != nil {
405 return "", "", err
406 }
407
408 validRemotes := getValidGithubRemoteURLs(remotes)
409 if len(validRemotes) > 0 {
410 for {
411 fmt.Println("\nDetected projects:")
412
413 // print valid remote github urls
414 for i, remote := range validRemotes {
415 fmt.Printf("[%d]: %v\n", i+1, remote)
416 }
417
418 fmt.Printf("\n[0]: Another project\n\n")
419 fmt.Printf("Select option: ")
420
421 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
422 if err != nil {
423 return "", "", err
424 }
425
426 line = strings.TrimSpace(line)
427
428 index, err := strconv.Atoi(line)
429 if err != nil || index < 0 || index > len(validRemotes) {
430 fmt.Println("invalid input")
431 continue
432 }
433
434 // if user want to enter another project url break this loop
435 if index == 0 {
436 break
437 }
438
439 // get owner and project with index
440 owner, project, _ := splitURL(validRemotes[index-1])
441 return owner, project, nil
442 }
443 }
444
445 // manually enter github url
446 for {
447 fmt.Print("Github project URL: ")
448
449 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
450 if err != nil {
451 return "", "", err
452 }
453
454 line = strings.TrimSpace(line)
455 if line == "" {
456 fmt.Println("URL is empty")
457 continue
458 }
459
460 // get owner and project from url
461 owner, project, err := splitURL(line)
462 if err != nil {
463 fmt.Println(err)
464 continue
465 }
466
467 return owner, project, nil
468 }
469}
470
471// splitURL extract the owner and project from a github repository URL. It will remove the
472// '.git' extension from the URL before parsing it.
473// Note that Github removes the '.git' extension from projects names at their creation
474func splitURL(url string) (owner string, project string, err error) {
475 cleanURL := strings.TrimSuffix(url, ".git")
476
477 re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
478 if err != nil {
479 panic("regexp compile:" + err.Error())
480 }
481
482 res := re.FindStringSubmatch(cleanURL)
483 if res == nil {
484 return "", "", ErrBadProjectURL
485 }
486
487 owner = res[1]
488 project = res[2]
489 return
490}
491
492func getValidGithubRemoteURLs(remotes map[string]string) []string {
493 urls := make([]string, 0, len(remotes))
494 for _, url := range remotes {
495 // split url can work again with shortURL
496 owner, project, err := splitURL(url)
497 if err == nil {
498 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
499 urls = append(urls, shortURL)
500 }
501 }
502
503 sort.Strings(urls)
504
505 return urls
506}
507
508func validateUsername(username string) (bool, error) {
509 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
510
511 client := &http.Client{
512 Timeout: defaultTimeout,
513 }
514
515 resp, err := client.Get(url)
516 if err != nil {
517 return false, err
518 }
519
520 err = resp.Body.Close()
521 if err != nil {
522 return false, err
523 }
524
525 return resp.StatusCode == http.StatusOK, nil
526}
527
528func validateProject(owner, project string, token *auth.Token) (bool, error) {
529 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
530
531 req, err := http.NewRequest("GET", url, nil)
532 if err != nil {
533 return false, err
534 }
535
536 // need the token for private repositories
537 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
538
539 client := &http.Client{
540 Timeout: defaultTimeout,
541 }
542
543 resp, err := client.Do(req)
544 if err != nil {
545 return false, err
546 }
547
548 err = resp.Body.Close()
549 if err != nil {
550 return false, err
551 }
552
553 return resp.StatusCode == http.StatusOK, nil
554}