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