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/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}