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