internal/control/repo.go
353 lines · 12398 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 err := policyValidateRepoName(name); err != nil {
94 return c.fail(protocol.ExitUsage, "%v", err)
95 }
96 ownerKind, ownerID := "user", c.User.ID
97 if owner != c.User.Username {
98 org, err := c.Store.OrgByName(owner)
99 if err != nil {
100 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
101 }
102 role, err := c.Store.OrgRole(org.ID, c.User.ID)
103 if err != nil {
104 return c.fail(protocol.ExitFailure, "%v", err)
105 }
106 if role != "admin" {
107 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
108 }
109 ownerKind, ownerID = "org", org.ID
110 }
111 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
112 if err != nil {
113 return c.fail(protocol.ExitFailure, "%v", err)
114 }
115 dir := RepoDir(c.Cfg.Server.Root, owner, name)
116 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
117 c.Store.DeleteRepo(id)
118 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
119 }
120 type out struct {
121 Path string `json:"path"`
122 Visibility string `json:"visibility"`
123 SSHURL string `json:"ssh_url"`
124 }
125 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
126 return c.emit(d, func(w io.Writer) {
127 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
128 })
129}
130
131func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
132
133func hostOf(siteURL string) string {
134 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
135 return strings.TrimSuffix(s, "/")
136}
137
138func runRepoList(c *Ctx, args []string) int {
139 repos, err := c.Store.ListReposForUser(c.User.ID)
140 if err != nil {
141 return c.fail(protocol.ExitFailure, "%v", err)
142 }
143 type out struct {
144 Path string `json:"path"`
145 Visibility string `json:"visibility"`
146 }
147 var ds []out
148 for _, r := range repos {
149 ds = append(ds, out{r.Path(), r.Visibility})
150 }
151 return c.emit(ds, func(w io.Writer) {
152 for _, d := range ds {
153 fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility)
154 }
155 })
156}
157
158func runRepoShow(c *Ctx, args []string) int {
159 if len(args) != 1 {
160 return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
161 }
162 repo, code := resolveRepo(c, args[0], policy.CanRead)
163 if code >= 0 {
164 return code
165 }
166 type out struct {
167 Path string `json:"path"`
168 Visibility string `json:"visibility"`
169 DefaultBranch string `json:"default_branch"`
170 ProtectedBranches []string `json:"protected_branches,omitempty"`
171 }
172 d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
173 return c.emit(d, func(w io.Writer) {
174 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
175 if len(d.ProtectedBranches) > 0 {
176 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
177 }
178 })
179}
180
181func runRepoDelete(c *Ctx, args []string) int {
182 var path string
183 var yes bool
184 for _, a := range args {
185 if a == "--yes" {
186 yes = true
187 } else if path == "" {
188 path = a
189 } else {
190 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
191 }
192 }
193 if path == "" {
194 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
195 }
196 repo, code := resolveRepo(c, path, policy.CanAdmin)
197 if code >= 0 {
198 return code
199 }
200 if !yes {
201 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
202 }
203 // Open MRs sourced from this repo keep working (targets own the
204 // objects) but must show that the source is gone.
205 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
206 return c.fail(protocol.ExitFailure, "%v", err)
207 }
208 if err := c.Store.DeleteRepo(repo.ID); err != nil {
209 return c.fail(protocol.ExitFailure, "%v", err)
210 }
211 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
212 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
213 }
214 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
215 fmt.Fprintf(w, "deleted %s\n", repo.Path())
216 })
217}
218
219func runAccessGrant(c *Ctx, args []string) int {
220 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
221 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
222 }
223 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
224 if code >= 0 {
225 return code
226 }
227 target, err := c.Store.UserByUsername(args[1])
228 if err != nil {
229 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
230 }
231 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
232 return c.fail(protocol.ExitFailure, "%v", err)
233 }
234 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
235 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
236}
237
238func runAccessRevoke(c *Ctx, args []string) int {
239 if len(args) != 2 {
240 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
241 }
242 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
243 if code >= 0 {
244 return code
245 }
246 target, err := c.Store.UserByUsername(args[1])
247 if err != nil {
248 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
249 }
250 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
251 if errors.Is(err, store.ErrNotFound) {
252 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
253 }
254 return c.fail(protocol.ExitFailure, "%v", err)
255 }
256 return c.emit(map[string]string{"revoked": target.Username},
257 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
258}
259
260func runAccessList(c *Ctx, args []string) int {
261 if len(args) != 1 {
262 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
263 }
264 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
265 if code >= 0 {
266 return code
267 }
268 entries, err := c.Store.ListAccess(repo.ID)
269 if err != nil {
270 return c.fail(protocol.ExitFailure, "%v", err)
271 }
272 type out struct {
273 User string `json:"user"`
274 Role string `json:"role"`
275 }
276 var ds []out
277 for _, e := range entries {
278 ds = append(ds, out{e.Username, e.Role})
279 }
280 return c.emit(ds, func(w io.Writer) {
281 for _, d := range ds {
282 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
283 }
284 })
285}
286
287func runSettingsShow(c *Ctx, args []string) int {
288 if len(args) != 1 {
289 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
290 }
291 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
292 if code >= 0 {
293 return code
294 }
295 return c.emit(repo.Settings, func(w io.Writer) {
296 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
297 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
298 })
299}
300
301func runGitDaemon(c *Ctx, args []string) int {
302 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
303 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
304 }
305 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
306 if code >= 0 {
307 return code
308 }
309 on := args[1] == "on"
310 if on && repo.Visibility != "public" {
311 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
312 }
313 if on && !c.Cfg.GitDaemon.Enabled {
314 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
315 }
316 s := repo.Settings
317 s.GitDaemon = on
318 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
319 return c.fail(protocol.ExitFailure, "%v", err)
320 }
321 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
322}
323
324func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
325func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
326
327func setProtect(c *Ctx, args []string, protect bool) int {
328 if len(args) != 2 {
329 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
330 }
331 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
332 if code >= 0 {
333 return code
334 }
335 branch := args[1]
336 s := repo.Settings
337 has := slices.Contains(s.ProtectedBranches, branch)
338 if protect && !has {
339 s.ProtectedBranches = append(s.ProtectedBranches, branch)
340 slices.Sort(s.ProtectedBranches)
341 }
342 if !protect && has {
343 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
344 }
345 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
346 return c.fail(protocol.ExitFailure, "%v", err)
347 }
348 verb := "protected"
349 if !protect {
350 verb = "unprotected"
351 }
352 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
353}