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