internal/control/repo.go

4485496a075180586eab7210ec46a6b6f4285770
gitbay/internal/control/repo.go history · blame · raw

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}