internal/control/repo.go
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}