1package gitlab
2
3import (
4 "bufio"
5 "fmt"
6 "net/url"
7 "os"
8 "regexp"
9 "sort"
10 "strconv"
11 "strings"
12 "time"
13
14 text "github.com/MichaelMure/go-term-text"
15 "github.com/pkg/errors"
16 "github.com/xanzy/go-gitlab"
17
18 "github.com/MichaelMure/git-bug/bridge/core"
19 "github.com/MichaelMure/git-bug/bridge/core/auth"
20 "github.com/MichaelMure/git-bug/cache"
21 "github.com/MichaelMure/git-bug/entity"
22 "github.com/MichaelMure/git-bug/repository"
23 "github.com/MichaelMure/git-bug/util/colors"
24)
25
26var (
27 ErrBadProjectURL = errors.New("bad project url")
28)
29
30func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
31 if params.Project != "" {
32 fmt.Println("warning: --project is ineffective for a gitlab bridge")
33 }
34 if params.Owner != "" {
35 fmt.Println("warning: --owner is ineffective for a gitlab bridge")
36 }
37
38 conf := make(core.Configuration)
39 var err error
40
41 if (params.CredPrefix != "" || params.TokenRaw != "") && params.URL == "" {
42 return nil, fmt.Errorf("you must provide a project URL to configure this bridge with a token")
43 }
44
45 var baseUrl string
46
47 switch {
48 case params.BaseURL != "":
49 baseUrl = params.BaseURL
50 default:
51 baseUrl, err = promptBaseUrlOptions()
52 if err != nil {
53 return nil, errors.Wrap(err, "base url prompt")
54 }
55 }
56
57 var url string
58
59 // get project url
60 switch {
61 case params.URL != "":
62 url = params.URL
63 default:
64 // terminal prompt
65 url, err = promptURL(repo, baseUrl)
66 if err != nil {
67 return nil, errors.Wrap(err, "url prompt")
68 }
69 }
70
71 if !strings.HasPrefix(url, params.BaseURL) {
72 return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, url)
73 }
74
75 user, err := repo.GetUserIdentity()
76 if err != nil {
77 return nil, err
78 }
79
80 var cred auth.Credential
81
82 switch {
83 case params.CredPrefix != "":
84 cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
85 if err != nil {
86 return nil, err
87 }
88 if cred.UserId() != user.Id() {
89 return nil, fmt.Errorf("selected credential don't match the user")
90 }
91 case params.TokenRaw != "":
92 cred = auth.NewToken(user.Id(), params.TokenRaw, target)
93 default:
94 cred, err = promptTokenOptions(repo, user.Id())
95 if err != nil {
96 return nil, err
97 }
98 }
99
100 token, ok := cred.(*auth.Token)
101 if !ok {
102 return nil, fmt.Errorf("the Gitlab bridge only handle token credentials")
103 }
104
105 // validate project url and get its ID
106 id, err := validateProjectURL(baseUrl, url, token)
107 if err != nil {
108 return nil, errors.Wrap(err, "project validation")
109 }
110
111 conf[core.ConfigKeyTarget] = target
112 conf[keyProjectID] = strconv.Itoa(id)
113 conf[keyGitlabBaseUrl] = baseUrl
114
115 err = g.ValidateConfig(conf)
116 if err != nil {
117 return nil, err
118 }
119
120 // don't forget to store the now known valid token
121 if !auth.IdExist(repo, cred.ID()) {
122 err = auth.Store(repo, cred)
123 if err != nil {
124 return nil, err
125 }
126 }
127
128 return conf, nil
129}
130
131func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
132 if v, ok := conf[core.ConfigKeyTarget]; !ok {
133 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
134 } else if v != target {
135 return fmt.Errorf("unexpected target name: %v", v)
136 }
137 if _, ok := conf[keyGitlabBaseUrl]; !ok {
138 return fmt.Errorf("missing %s key", keyGitlabBaseUrl)
139 }
140 if _, ok := conf[keyProjectID]; !ok {
141 return fmt.Errorf("missing %s key", keyProjectID)
142 }
143
144 return nil
145}
146
147func promptBaseUrlOptions() (string, error) {
148 for {
149 fmt.Printf("Gitlab base url:\n")
150 fmt.Printf("[0]: https://gitlab.com\n")
151 fmt.Printf("[1]: enter your own base url\n")
152 fmt.Printf("Select option: ")
153
154 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
155 if err != nil {
156 return "", err
157 }
158
159 line = strings.TrimSpace(line)
160
161 index, err := strconv.Atoi(line)
162 if err != nil || index < 0 || index > 1 {
163 fmt.Println("invalid input")
164 continue
165 }
166
167 switch index {
168 case 0:
169 return defaultBaseURL, nil
170 case 1:
171 return promptBaseUrl()
172 }
173 }
174}
175
176func promptBaseUrl() (string, error) {
177 for {
178 fmt.Print("base url: ")
179
180 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
181 if err != nil {
182 return "", err
183 }
184
185 line = strings.TrimSpace(line)
186
187 ok, err := validateBaseUrl(line)
188 if err != nil {
189 return "", err
190 }
191 if ok {
192 return line, nil
193 }
194 }
195}
196
197func validateBaseUrl(baseUrl string) (bool, error) {
198 u, err := url.Parse(baseUrl)
199 if err != nil {
200 return false, err
201 }
202 return u.Scheme != "" && u.Host != "", nil
203}
204
205func promptTokenOptions(repo repository.RepoConfig, userId entity.Id) (auth.Credential, error) {
206 for {
207 creds, err := auth.List(repo, auth.WithUserId(userId), auth.WithTarget(target), auth.WithKind(auth.KindToken))
208 if err != nil {
209 return nil, err
210 }
211
212 // if we don't have existing token, fast-track to the token prompt
213 if len(creds) == 0 {
214 value, err := promptToken()
215 if err != nil {
216 return nil, err
217 }
218 return auth.NewToken(userId, value, target), nil
219 }
220
221 fmt.Println()
222 fmt.Println("[1]: enter my token")
223
224 fmt.Println()
225 fmt.Println("Existing tokens for Gitlab:")
226
227 sort.Sort(auth.ById(creds))
228 for i, cred := range creds {
229 token := cred.(*auth.Token)
230 fmt.Printf("[%d]: %s => %s (%s)\n",
231 i+2,
232 colors.Cyan(token.ID().Human()),
233 colors.Red(text.TruncateMax(token.Value, 10)),
234 token.CreateTime().Format(time.RFC822),
235 )
236 }
237
238 fmt.Println()
239 fmt.Print("Select option: ")
240
241 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
242 fmt.Println()
243 if err != nil {
244 return nil, err
245 }
246
247 line = strings.TrimSpace(line)
248 index, err := strconv.Atoi(line)
249 if err != nil || index < 1 || index > len(creds)+1 {
250 fmt.Println("invalid input")
251 continue
252 }
253
254 switch index {
255 case 1:
256 value, err := promptToken()
257 if err != nil {
258 return nil, err
259 }
260 return auth.NewToken(userId, value, target), nil
261 default:
262 return creds[index-2], nil
263 }
264 }
265}
266
267func promptToken() (string, error) {
268 fmt.Println("You can generate a new token by visiting https://gitlab.com/profile/personal_access_tokens.")
269 fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.")
270 fmt.Println()
271 fmt.Println("'api' access scope: to be able to make api calls")
272 fmt.Println()
273
274 re, err := regexp.Compile(`^[a-zA-Z0-9\-\_]{20}`)
275 if err != nil {
276 panic("regexp compile:" + err.Error())
277 }
278
279 for {
280 fmt.Print("Enter token: ")
281
282 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
283 if err != nil {
284 return "", err
285 }
286
287 token := strings.TrimSpace(line)
288 if re.MatchString(token) {
289 return token, nil
290 }
291
292 fmt.Println("token format is invalid")
293 }
294}
295
296func promptURL(repo repository.RepoCommon, baseUrl string) (string, error) {
297 // remote suggestions
298 remotes, err := repo.GetRemotes()
299 if err != nil {
300 return "", errors.Wrap(err, "getting remotes")
301 }
302
303 validRemotes := getValidGitlabRemoteURLs(baseUrl, remotes)
304 if len(validRemotes) > 0 {
305 for {
306 fmt.Println("\nDetected projects:")
307
308 // print valid remote gitlab urls
309 for i, remote := range validRemotes {
310 fmt.Printf("[%d]: %v\n", i+1, remote)
311 }
312
313 fmt.Printf("\n[0]: Another project\n\n")
314 fmt.Printf("Select option: ")
315
316 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
317 if err != nil {
318 return "", err
319 }
320
321 line = strings.TrimSpace(line)
322
323 index, err := strconv.Atoi(line)
324 if err != nil || index < 0 || index > len(validRemotes) {
325 fmt.Println("invalid input")
326 continue
327 }
328
329 // if user want to enter another project url break this loop
330 if index == 0 {
331 break
332 }
333
334 return validRemotes[index-1], nil
335 }
336 }
337
338 // manually enter gitlab url
339 for {
340 fmt.Print("Gitlab project URL: ")
341
342 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
343 if err != nil {
344 return "", err
345 }
346
347 url := strings.TrimSpace(line)
348 if url == "" {
349 fmt.Println("URL is empty")
350 continue
351 }
352
353 return url, nil
354 }
355}
356
357func getProjectPath(baseUrl, projectUrl string) (string, error) {
358 cleanUrl := strings.TrimSuffix(projectUrl, ".git")
359 cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1)
360 objectUrl, err := url.Parse(cleanUrl)
361 if err != nil {
362 return "", ErrBadProjectURL
363 }
364
365 objectBaseUrl, err := url.Parse(baseUrl)
366 if err != nil {
367 return "", ErrBadProjectURL
368 }
369
370 if objectUrl.Hostname() != objectBaseUrl.Hostname() {
371 return "", fmt.Errorf("base url and project url hostnames doesn't match")
372 }
373 return objectUrl.Path[1:], nil
374}
375
376func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []string {
377 urls := make([]string, 0, len(remotes))
378 for _, u := range remotes {
379 path, err := getProjectPath(baseUrl, u)
380 if err != nil {
381 continue
382 }
383
384 urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, path))
385 }
386
387 return urls
388}
389
390func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) {
391 projectPath, err := getProjectPath(baseUrl, url)
392 if err != nil {
393 return 0, err
394 }
395
396 client, err := buildClient(baseUrl, token)
397 if err != nil {
398 return 0, err
399 }
400
401 project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{})
402 if err != nil {
403 return 0, errors.Wrap(err, "wrong token scope ou inexistent project")
404 }
405
406 return project.ID, nil
407}