internal/control/import.go

e2a32d5f8d59e4213571c602bd9009b6c8fa86ed
gitbay/internal/control/import.go history · blame · raw

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