internal/control/repo.go

b09bf9f4c011abba74725b44e1fffaa24655bc67
gitbay/internal/control/repo.go history · blame · raw

353 lines · 12462 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", "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>", ReadOnly: true, Run: runAccessList})
 41	register(Command{Path: []string{"repo", "settings", "show"},
 42		Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, 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}