internal/control/import.go

39535a8e16d8dfc0189bce59511cfb0d7d019fb5
gitbay/internal/control/import.go history · blame · raw

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