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