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