internal/control/import.go

189 lines · 6724 bytes

  1package control
  2
  3import (
  4	"bufio"
  5	"context"
  6	"fmt"
  7	"io"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"time"
 12
 13	"gitbay.org/gitbay/internal/gitpin"
 14	"gitbay.org/gitbay/internal/gitutil"
 15	"gitbay.org/gitbay/internal/policy"
 16	"gitbay.org/gitbay/internal/protocol"
 17)
 18
 19func init() {
 20	register(Command{Path: []string{"repo", "import"},
 21		Summary: "server-side mirror of a foreign repository",
 22		Usage:   "repo import <owner/name> --from <url> [--private] [--token-stdin]",
 23		Flags: []Flag{
 24			{"--from", "<url>", "the repository to import", ""},
 25			{"--private", "", "create it private", ""},
 26			{"--token-stdin", "", "read a credential token from stdin", ""},
 27		},
 28		Examples:   []string{"repo import krz/imported --from https://github.com/krz/old.git"},
 29		ReadsStdin: true, Run: runRepoImport})
 30}
 31
 32// askpassScript answers git's credential prompts from the environment, so
 33// the token never appears on a command line or in a URL. Username prompts
 34// get a placeholder (GitHub and GitLab ignore it for token auth).
 35const askpassScript = `#!/bin/sh
 36case "$1" in
 37  Username*) echo "x-access-token" ;;
 38  *)         echo "${GITBAY_IMPORT_TOKEN}" ;;
 39esac
 40`
 41
 42// importLookup resolves the hosts repo import and repo import-issues
 43// connect to; tests replace it.
 44var importLookup gitpin.Lookup = gitpin.LookupIP
 45
 46func runRepoImport(c *Ctx, args []string) int {
 47	f, err := c.parseArgs(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1,
 48		Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"})
 49	if err != nil {
 50		return c.fail(protocol.ExitUsage, "%v", err)
 51	}
 52	path, from, private, tokenStdin := f.pos(0), f.Value("--from"), f.Has("--private"), f.Has("--token-stdin")
 53	if path == "" || from == "" {
 54		return c.usage()
 55	}
 56	owner, name, ok := strings.Cut(path, "/")
 57	if !ok {
 58		return c.usage()
 59	}
 60	if err := policy.ValidateName(name); err != nil {
 61		return c.failInput(err)
 62	}
 63	// Same ownership rule as repo create: yourself, or an org you admin.
 64	ownerKind, ownerID := "user", c.User.ID
 65	if owner != c.User.Username {
 66		org, err := c.Store.OrgByName(owner)
 67		if err != nil {
 68			return c.fail(protocol.ExitDenied, "cannot import under %q: not you and not an organization you can see", owner)
 69		}
 70		role, err := c.Store.OrgRole(org.ID, c.User.ID)
 71		if err != nil {
 72			return c.fail(protocol.ExitFailure, "%v", err)
 73		}
 74		if role != "admin" {
 75			return c.fail(protocol.ExitDenied, "only admins of %s can import repositories there", owner)
 76		}
 77		ownerKind, ownerID = "org", org.ID
 78	}
 79	if ownerKind == "user" {
 80		if code := checkRepoQuota(c); code >= 0 {
 81			return code
 82		}
 83	}
 84
 85	// http and https only. git:// has no equivalent of curl's resolve
 86	// list, so its connection cannot be held to a checked address;
 87	// file:// would read the server's filesystem, and ssh:// would use
 88	// the server's own keys.
 89	if !strings.HasPrefix(from, "https://") && !strings.HasPrefix(from, "http://") {
 90		return c.fail(protocol.ExitUsage, "import fetches over http:// and https:// only; use the repository's https:// URL")
 91	}
 92	if strings.ContainsAny(from, "@") {
 93		// Credentials belong on stdin, not in the URL where they would
 94		// land in process listings and logs.
 95		return c.fail(protocol.ExitUsage, "do not embed credentials in the URL; use --token-stdin")
 96	}
 97	if strings.ContainsAny(from, "?#") {
 98		// The URL is logged and recorded; a query could carry a token.
 99		return c.fail(protocol.ExitUsage, "use the plain clone URL, without a query or fragment; credentials go on stdin with --token-stdin, never in the URL")
100	}
101
102	// Resolve and check the host now and hold git to those addresses,
103	// as mirror sync does (#298).
104	timeout := time.Duration(c.Cfg.Limits.CloneTimeoutSec) * time.Second
105	ctx, cancel := context.WithTimeout(context.Background(), timeout)
106	defer cancel()
107	if err := gitpin.CheckGit(ctx); err != nil {
108		return c.fail(protocol.ExitFailure, "import unavailable: %v", err)
109	}
110	remote, err := gitpin.Resolve(ctx, importLookup, from, c.Cfg.Webhooks.AllowLocal)
111	if err != nil {
112		return c.fail(protocol.ExitFailure, "%v", err)
113	}
114
115	// The token is read from stdin and handed to git via GIT_ASKPASS and
116	// the environment — never argv, never the database, never a log line.
117	env := gitpin.Env(c.Cfg.Server.Root)
118	if tokenStdin {
119		token, err := bufio.NewReader(io.LimitReader(c.Stdin, 4096)).ReadString('\n')
120		if err != nil && err != io.EOF {
121			return c.fail(protocol.ExitFailure, "reading token: %v", err)
122		}
123		token = strings.TrimSpace(token)
124		if token == "" {
125			return c.fail(protocol.ExitUsage, "--token-stdin given but stdin held no token")
126		}
127		askpass := filepath.Join(c.Cfg.Server.Root, "askpass.sh")
128		if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
129			return c.fail(protocol.ExitFailure, "%v", err)
130		}
131		env = append(env, "GIT_ASKPASS="+askpass, "GITBAY_IMPORT_TOKEN="+token)
132	}
133
134	visibility := "public"
135	if private {
136		visibility = "private"
137	}
138	// The early check above fails fast; this one holds the lock across
139	// the insert so a concurrent create cannot slip past the count.
140	repoCreateMu.Lock()
141	if ownerKind == "user" {
142		if code := checkRepoQuota(c); code >= 0 {
143			repoCreateMu.Unlock()
144			return code
145		}
146	}
147	id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
148	repoCreateMu.Unlock()
149	if err != nil {
150		return c.fail(protocol.ExitFailure, "%v", err)
151	}
152	dir := RepoDir(c.Cfg.Server.Root, owner, name)
153	cleanup := func() {
154		c.Store.DeleteRepo(id)
155		os.RemoveAll(dir)
156	}
157	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
158		cleanup()
159		return c.fail(protocol.ExitFailure, "%v", err)
160	}
161
162	fmt.Fprintf(c.Stderr, "importing %s into %s ...\n", from, path)
163	if err := gitutil.FetchMirror(ctx, dir, from, c.Stderr, remote.Args(), env); err != nil {
164		cleanup()
165		return c.fail(protocol.ExitFailure, "import failed: %v", err)
166	}
167
168	branch, err := gitutil.RemoteDefaultBranch(ctx, dir, from, remote.Args(), env)
169	if err != nil {
170		branch = "main" // remote gone quiet after the fetch; keep the default
171	}
172	if _, rerr := gitutil.ResolveRef(dir, "refs/heads/"+branch); rerr == nil {
173		gitutil.SetHead(dir, branch)
174		c.Store.UpdateDefaultBranch(id, branch)
175	}
176	c.Store.RequestSymbolIndex(id, false)
177
178	c.Store.RecordEvent(id, c.User.ID, "repo.imported", fmt.Sprintf(`{"from":%q}`, from))
179	type out struct {
180		Path          string `json:"path"`
181		Visibility    string `json:"visibility"`
182		DefaultBranch string `json:"default_branch"`
183	}
184	d := out{path, visibility, branch}
185	return c.emit(d, func(w io.Writer) {
186		fmt.Fprintf(w, "imported %s (%s, default %s)\nnote: git data only — issues and pull requests do not transfer\n",
187			d.Path, d.Visibility, d.DefaultBranch)
188	})
189}