internal/control/repo.go
341 lines · 11992 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// RepoDir returns the on-disk path for a repository.
19func RepoDir(root, owner, name string) string {
20 return filepath.Join(root, "repos", owner, name+".git")
21}
22
23// HooksDir is the shared core.hooksPath directory.
24func HooksDir(root string) string { return filepath.Join(root, "hooks") }
25
26func init() {
27 register(Command{Path: []string{"repo", "create"},
28 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
29 register(Command{Path: []string{"repo", "list"},
30 Summary: "list repositories you own or can access", Run: runRepoList})
31 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", Run: runRepoShow})
33 register(Command{Path: []string{"repo", "delete"},
34 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
35 register(Command{Path: []string{"repo", "access", "grant"},
36 Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
37 register(Command{Path: []string{"repo", "access", "revoke"},
38 Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
39 register(Command{Path: []string{"repo", "access", "list"},
40 Summary: "list access grants: repo access list <owner/name>", Run: runAccessList})
41 register(Command{Path: []string{"repo", "settings", "show"},
42 Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow})
43 register(Command{Path: []string{"repo", "settings", "protect"},
44 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
45 register(Command{Path: []string{"repo", "settings", "unprotect"},
46 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
47 register(Command{Path: []string{"repo", "settings", "git-daemon"},
48 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
49}
50
51// resolveRepo loads a repo and checks the given permission for c.User.
52func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
53 repo, err := c.Store.RepoByPath(path)
54 if err != nil {
55 if errors.Is(err, store.ErrNotFound) {
56 // Same message whether it doesn't exist or is invisible.
57 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
58 }
59 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
60 }
61 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
62 if err != nil {
63 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
64 }
65 if !check(c.User, repo, grant) {
66 if !policy.CanRead(c.User, repo, grant) {
67 // Invisible repos 404, per the enumeration rule.
68 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
69 }
70 return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
71 }
72 return repo, -1
73}
74
75func runRepoCreate(c *Ctx, args []string) int {
76 visibility := "public"
77 var path string
78 for _, a := range args {
79 switch a {
80 case "--private":
81 visibility = "private"
82 default:
83 if path != "" {
84 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
85 }
86 path = a
87 }
88 }
89 owner, name, ok := strings.Cut(path, "/")
90 if !ok {
91 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
92 }
93 if owner != c.User.Username {
94 return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner)
95 }
96 if err := policyValidateRepoName(name); err != nil {
97 return c.fail(protocol.ExitUsage, "%v", err)
98 }
99 id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility)
100 if err != nil {
101 return c.fail(protocol.ExitFailure, "%v", err)
102 }
103 dir := RepoDir(c.Cfg.Server.Root, owner, name)
104 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
105 c.Store.DeleteRepo(id)
106 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
107 }
108 type out struct {
109 Path string `json:"path"`
110 Visibility string `json:"visibility"`
111 SSHURL string `json:"ssh_url"`
112 }
113 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
114 return c.emit(d, func(w io.Writer) {
115 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
116 })
117}
118
119func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
120
121func hostOf(siteURL string) string {
122 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
123 return strings.TrimSuffix(s, "/")
124}
125
126func runRepoList(c *Ctx, args []string) int {
127 repos, err := c.Store.ListReposForUser(c.User.ID)
128 if err != nil {
129 return c.fail(protocol.ExitFailure, "%v", err)
130 }
131 type out struct {
132 Path string `json:"path"`
133 Visibility string `json:"visibility"`
134 }
135 var ds []out
136 for _, r := range repos {
137 ds = append(ds, out{r.Path(), r.Visibility})
138 }
139 return c.emit(ds, func(w io.Writer) {
140 for _, d := range ds {
141 fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility)
142 }
143 })
144}
145
146func runRepoShow(c *Ctx, args []string) int {
147 if len(args) != 1 {
148 return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
149 }
150 repo, code := resolveRepo(c, args[0], policy.CanRead)
151 if code >= 0 {
152 return code
153 }
154 type out struct {
155 Path string `json:"path"`
156 Visibility string `json:"visibility"`
157 DefaultBranch string `json:"default_branch"`
158 ProtectedBranches []string `json:"protected_branches,omitempty"`
159 }
160 d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
161 return c.emit(d, func(w io.Writer) {
162 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
163 if len(d.ProtectedBranches) > 0 {
164 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
165 }
166 })
167}
168
169func runRepoDelete(c *Ctx, args []string) int {
170 var path string
171 var yes bool
172 for _, a := range args {
173 if a == "--yes" {
174 yes = true
175 } else if path == "" {
176 path = a
177 } else {
178 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
179 }
180 }
181 if path == "" {
182 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
183 }
184 repo, code := resolveRepo(c, path, policy.CanAdmin)
185 if code >= 0 {
186 return code
187 }
188 if !yes {
189 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
190 }
191 // Open MRs sourced from this repo keep working (targets own the
192 // objects) but must show that the source is gone.
193 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
194 return c.fail(protocol.ExitFailure, "%v", err)
195 }
196 if err := c.Store.DeleteRepo(repo.ID); err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
200 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
201 }
202 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
203 fmt.Fprintf(w, "deleted %s\n", repo.Path())
204 })
205}
206
207func runAccessGrant(c *Ctx, args []string) int {
208 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
209 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
210 }
211 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
212 if code >= 0 {
213 return code
214 }
215 target, err := c.Store.UserByUsername(args[1])
216 if err != nil {
217 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
218 }
219 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
220 return c.fail(protocol.ExitFailure, "%v", err)
221 }
222 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
223 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
224}
225
226func runAccessRevoke(c *Ctx, args []string) int {
227 if len(args) != 2 {
228 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
229 }
230 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
231 if code >= 0 {
232 return code
233 }
234 target, err := c.Store.UserByUsername(args[1])
235 if err != nil {
236 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
237 }
238 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
239 if errors.Is(err, store.ErrNotFound) {
240 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
241 }
242 return c.fail(protocol.ExitFailure, "%v", err)
243 }
244 return c.emit(map[string]string{"revoked": target.Username},
245 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
246}
247
248func runAccessList(c *Ctx, args []string) int {
249 if len(args) != 1 {
250 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
251 }
252 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
253 if code >= 0 {
254 return code
255 }
256 entries, err := c.Store.ListAccess(repo.ID)
257 if err != nil {
258 return c.fail(protocol.ExitFailure, "%v", err)
259 }
260 type out struct {
261 User string `json:"user"`
262 Role string `json:"role"`
263 }
264 var ds []out
265 for _, e := range entries {
266 ds = append(ds, out{e.Username, e.Role})
267 }
268 return c.emit(ds, func(w io.Writer) {
269 for _, d := range ds {
270 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
271 }
272 })
273}
274
275func runSettingsShow(c *Ctx, args []string) int {
276 if len(args) != 1 {
277 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
278 }
279 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
280 if code >= 0 {
281 return code
282 }
283 return c.emit(repo.Settings, func(w io.Writer) {
284 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
285 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
286 })
287}
288
289func runGitDaemon(c *Ctx, args []string) int {
290 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
291 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
292 }
293 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
294 if code >= 0 {
295 return code
296 }
297 on := args[1] == "on"
298 if on && repo.Visibility != "public" {
299 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
300 }
301 if on && !c.Cfg.GitDaemon.Enabled {
302 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
303 }
304 s := repo.Settings
305 s.GitDaemon = on
306 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
307 return c.fail(protocol.ExitFailure, "%v", err)
308 }
309 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
310}
311
312func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
313func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
314
315func setProtect(c *Ctx, args []string, protect bool) int {
316 if len(args) != 2 {
317 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
318 }
319 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
320 if code >= 0 {
321 return code
322 }
323 branch := args[1]
324 s := repo.Settings
325 has := slices.Contains(s.ProtectedBranches, branch)
326 if protect && !has {
327 s.ProtectedBranches = append(s.ProtectedBranches, branch)
328 slices.Sort(s.ProtectedBranches)
329 }
330 if !protect && has {
331 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
332 }
333 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
334 return c.fail(protocol.ExitFailure, "%v", err)
335 }
336 verb := "protected"
337 if !protect {
338 verb = "unprotected"
339 }
340 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
341}