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