internal/control/repo.go
408 lines · 14752 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", ReadOnly: true, Run: runRepoList})
31 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
33 register(Command{Path: []string{"repo", "transfer"},
34 Summary: "move a repository to another owner: repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer})
35 register(Command{Path: []string{"repo", "delete"},
36 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
37 register(Command{Path: []string{"repo", "access", "grant"},
38 Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
39 register(Command{Path: []string{"repo", "access", "revoke"},
40 Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
41 register(Command{Path: []string{"repo", "access", "list"},
42 Summary: "list access grants: repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
43 register(Command{Path: []string{"repo", "settings", "show"},
44 Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow})
45 register(Command{Path: []string{"repo", "settings", "protect"},
46 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
47 register(Command{Path: []string{"repo", "settings", "unprotect"},
48 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
49 register(Command{Path: []string{"repo", "settings", "git-daemon"},
50 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
51}
52
53// resolveRepo loads a repo and checks the given permission for c.User.
54func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
55 repo, err := c.Store.RepoByPath(path)
56 if err != nil {
57 if errors.Is(err, store.ErrNotFound) {
58 // Same message whether it doesn't exist or is invisible.
59 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
60 }
61 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
62 }
63 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
64 if err != nil {
65 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
66 }
67 if !check(c.User, repo, grant) {
68 if !policy.CanRead(c.User, repo, grant) {
69 // Invisible repos 404, per the enumeration rule.
70 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
71 }
72 return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
73 }
74 return repo, -1
75}
76
77func runRepoCreate(c *Ctx, args []string) int {
78 visibility := "public"
79 var path string
80 for _, a := range args {
81 switch a {
82 case "--private":
83 visibility = "private"
84 default:
85 if path != "" {
86 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
87 }
88 path = a
89 }
90 }
91 owner, name, ok := strings.Cut(path, "/")
92 if !ok {
93 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
94 }
95 if err := policyValidateRepoName(name); err != nil {
96 return c.fail(protocol.ExitUsage, "%v", err)
97 }
98 ownerKind, ownerID := "user", c.User.ID
99 if owner != c.User.Username {
100 org, err := c.Store.OrgByName(owner)
101 if err != nil {
102 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
103 }
104 role, err := c.Store.OrgRole(org.ID, c.User.ID)
105 if err != nil {
106 return c.fail(protocol.ExitFailure, "%v", err)
107 }
108 if role != "admin" {
109 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
110 }
111 ownerKind, ownerID = "org", org.ID
112 }
113 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
114 if err != nil {
115 return c.fail(protocol.ExitFailure, "%v", err)
116 }
117 dir := RepoDir(c.Cfg.Server.Root, owner, name)
118 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
119 c.Store.DeleteRepo(id)
120 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
121 }
122 type out struct {
123 Path string `json:"path"`
124 Visibility string `json:"visibility"`
125 SSHURL string `json:"ssh_url"`
126 }
127 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
128 return c.emit(d, func(w io.Writer) {
129 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
130 })
131}
132
133func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
134
135func hostOf(siteURL string) string {
136 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
137 return strings.TrimSuffix(s, "/")
138}
139
140func runRepoList(c *Ctx, args []string) int {
141 repos, err := c.Store.ListReposForUser(c.User.ID)
142 if err != nil {
143 return c.fail(protocol.ExitFailure, "%v", err)
144 }
145 type out struct {
146 Path string `json:"path"`
147 Visibility string `json:"visibility"`
148 }
149 var ds []out
150 for _, r := range repos {
151 ds = append(ds, out{r.Path(), r.Visibility})
152 }
153 return c.emit(ds, func(w io.Writer) {
154 for _, d := range ds {
155 fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility)
156 }
157 })
158}
159
160func runRepoShow(c *Ctx, args []string) int {
161 if len(args) != 1 {
162 return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
163 }
164 repo, code := resolveRepo(c, args[0], policy.CanRead)
165 if code >= 0 {
166 return code
167 }
168 type out struct {
169 Path string `json:"path"`
170 Visibility string `json:"visibility"`
171 DefaultBranch string `json:"default_branch"`
172 ProtectedBranches []string `json:"protected_branches,omitempty"`
173 }
174 d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
175 return c.emit(d, func(w io.Writer) {
176 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
177 if len(d.ProtectedBranches) > 0 {
178 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
179 }
180 })
181}
182
183func runRepoTransfer(c *Ctx, args []string) int {
184 if len(args) != 2 {
185 return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
186 }
187 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
188 if code >= 0 {
189 return code
190 }
191 newOwner := args[1]
192 if newOwner == repo.OwnerName {
193 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
194 }
195
196 // Target: yourself, or an org you admin — same rule as repo create.
197 newKind, newID := "", int64(0)
198 if newOwner == c.User.Username {
199 newKind, newID = "user", c.User.ID
200 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
201 role, err := c.Store.OrgRole(org.ID, c.User.ID)
202 if err != nil {
203 return c.fail(protocol.ExitFailure, "%v", err)
204 }
205 if role != "admin" {
206 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
207 }
208 newKind, newID = "org", org.ID
209 } else {
210 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
211 }
212
213 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
214 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
215 if _, err := os.Stat(newDir); err == nil {
216 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
217 }
218 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
219 return c.fail(protocol.ExitUsage, "%v", err)
220 }
221 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
222 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
223 return c.fail(protocol.ExitFailure, "%v", err)
224 }
225 if err := os.Rename(oldDir, newDir); err != nil {
226 // Keep name and disk consistent: revert the database change.
227 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
228 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
229 }
230 newPath := newOwner + "/" + repo.Name
231 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
232 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
233 })
234}
235
236func runRepoDelete(c *Ctx, args []string) int {
237 var path string
238 var yes bool
239 for _, a := range args {
240 if a == "--yes" {
241 yes = true
242 } else if path == "" {
243 path = a
244 } else {
245 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
246 }
247 }
248 if path == "" {
249 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
250 }
251 repo, code := resolveRepo(c, path, policy.CanAdmin)
252 if code >= 0 {
253 return code
254 }
255 if !yes {
256 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
257 }
258 // Open MRs sourced from this repo keep working (targets own the
259 // objects) but must show that the source is gone.
260 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
261 return c.fail(protocol.ExitFailure, "%v", err)
262 }
263 if err := c.Store.DeleteRepo(repo.ID); err != nil {
264 return c.fail(protocol.ExitFailure, "%v", err)
265 }
266 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
267 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
268 }
269 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
270 fmt.Fprintf(w, "deleted %s\n", repo.Path())
271 })
272}
273
274func runAccessGrant(c *Ctx, args []string) int {
275 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
276 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
277 }
278 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
279 if code >= 0 {
280 return code
281 }
282 target, err := c.Store.UserByUsername(args[1])
283 if err != nil {
284 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
285 }
286 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
287 return c.fail(protocol.ExitFailure, "%v", err)
288 }
289 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
290 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
291}
292
293func runAccessRevoke(c *Ctx, args []string) int {
294 if len(args) != 2 {
295 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
296 }
297 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
298 if code >= 0 {
299 return code
300 }
301 target, err := c.Store.UserByUsername(args[1])
302 if err != nil {
303 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
304 }
305 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
306 if errors.Is(err, store.ErrNotFound) {
307 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
308 }
309 return c.fail(protocol.ExitFailure, "%v", err)
310 }
311 return c.emit(map[string]string{"revoked": target.Username},
312 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
313}
314
315func runAccessList(c *Ctx, args []string) int {
316 if len(args) != 1 {
317 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
318 }
319 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
320 if code >= 0 {
321 return code
322 }
323 entries, err := c.Store.ListAccess(repo.ID)
324 if err != nil {
325 return c.fail(protocol.ExitFailure, "%v", err)
326 }
327 type out struct {
328 User string `json:"user"`
329 Role string `json:"role"`
330 }
331 var ds []out
332 for _, e := range entries {
333 ds = append(ds, out{e.Username, e.Role})
334 }
335 return c.emit(ds, func(w io.Writer) {
336 for _, d := range ds {
337 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
338 }
339 })
340}
341
342func runSettingsShow(c *Ctx, args []string) int {
343 if len(args) != 1 {
344 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
345 }
346 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
347 if code >= 0 {
348 return code
349 }
350 return c.emit(repo.Settings, func(w io.Writer) {
351 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
352 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
353 })
354}
355
356func runGitDaemon(c *Ctx, args []string) int {
357 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
358 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
359 }
360 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
361 if code >= 0 {
362 return code
363 }
364 on := args[1] == "on"
365 if on && repo.Visibility != "public" {
366 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
367 }
368 if on && !c.Cfg.GitDaemon.Enabled {
369 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
370 }
371 s := repo.Settings
372 s.GitDaemon = on
373 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
374 return c.fail(protocol.ExitFailure, "%v", err)
375 }
376 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
377}
378
379func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
380func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
381
382func setProtect(c *Ctx, args []string, protect bool) int {
383 if len(args) != 2 {
384 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
385 }
386 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
387 if code >= 0 {
388 return code
389 }
390 branch := args[1]
391 s := repo.Settings
392 has := slices.Contains(s.ProtectedBranches, branch)
393 if protect && !has {
394 s.ProtectedBranches = append(s.ProtectedBranches, branch)
395 slices.Sort(s.ProtectedBranches)
396 }
397 if !protect && has {
398 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
399 }
400 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
401 return c.fail(protocol.ExitFailure, "%v", err)
402 }
403 verb := "protected"
404 if !protect {
405 verb = "unprotected"
406 }
407 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
408}