internal/control/import.go

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

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