internal/control/import.go
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}