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}