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