config.go

  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}