internal/control/repo.go

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

336 lines · 11798 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"os"
  8	"path/filepath"
  9	"slices"
 10	"strings"
 11
 12	"github.com/krazywarez/forge/internal/gitutil"
 13	"github.com/krazywarez/forge/internal/policy"
 14	"github.com/krazywarez/forge/internal/protocol"
 15	"github.com/krazywarez/forge/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	if err := c.Store.DeleteRepo(repo.ID); err != nil {
192		return c.fail(protocol.ExitFailure, "%v", err)
193	}
194	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
195		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
196	}
197	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
198		fmt.Fprintf(w, "deleted %s\n", repo.Path())
199	})
200}
201
202func runAccessGrant(c *Ctx, args []string) int {
203	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
204		return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
205	}
206	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
207	if code >= 0 {
208		return code
209	}
210	target, err := c.Store.UserByUsername(args[1])
211	if err != nil {
212		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
213	}
214	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
215		return c.fail(protocol.ExitFailure, "%v", err)
216	}
217	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
218		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
219}
220
221func runAccessRevoke(c *Ctx, args []string) int {
222	if len(args) != 2 {
223		return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
224	}
225	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
226	if code >= 0 {
227		return code
228	}
229	target, err := c.Store.UserByUsername(args[1])
230	if err != nil {
231		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
232	}
233	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
234		if errors.Is(err, store.ErrNotFound) {
235			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
236		}
237		return c.fail(protocol.ExitFailure, "%v", err)
238	}
239	return c.emit(map[string]string{"revoked": target.Username},
240		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
241}
242
243func runAccessList(c *Ctx, args []string) int {
244	if len(args) != 1 {
245		return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
246	}
247	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
248	if code >= 0 {
249		return code
250	}
251	entries, err := c.Store.ListAccess(repo.ID)
252	if err != nil {
253		return c.fail(protocol.ExitFailure, "%v", err)
254	}
255	type out struct {
256		User string `json:"user"`
257		Role string `json:"role"`
258	}
259	var ds []out
260	for _, e := range entries {
261		ds = append(ds, out{e.Username, e.Role})
262	}
263	return c.emit(ds, func(w io.Writer) {
264		for _, d := range ds {
265			fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
266		}
267	})
268}
269
270func runSettingsShow(c *Ctx, args []string) int {
271	if len(args) != 1 {
272		return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
273	}
274	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
275	if code >= 0 {
276		return code
277	}
278	return c.emit(repo.Settings, func(w io.Writer) {
279		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
280			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
281	})
282}
283
284func runGitDaemon(c *Ctx, args []string) int {
285	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
286		return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
287	}
288	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
289	if code >= 0 {
290		return code
291	}
292	on := args[1] == "on"
293	if on && repo.Visibility != "public" {
294		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
295	}
296	if on && !c.Cfg.GitDaemon.Enabled {
297		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
298	}
299	s := repo.Settings
300	s.GitDaemon = on
301	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
302		return c.fail(protocol.ExitFailure, "%v", err)
303	}
304	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
305}
306
307func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
308func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
309
310func setProtect(c *Ctx, args []string, protect bool) int {
311	if len(args) != 2 {
312		return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
313	}
314	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
315	if code >= 0 {
316		return code
317	}
318	branch := args[1]
319	s := repo.Settings
320	has := slices.Contains(s.ProtectedBranches, branch)
321	if protect && !has {
322		s.ProtectedBranches = append(s.ProtectedBranches, branch)
323		slices.Sort(s.ProtectedBranches)
324	}
325	if !protect && has {
326		s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
327	}
328	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
329		return c.fail(protocol.ExitFailure, "%v", err)
330	}
331	verb := "protected"
332	if !protect {
333		verb = "unprotected"
334	}
335	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
336}