config.go

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