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