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