feat(repos): cap accounts and organisations at six repositories
5 files changed, +37 −2
- README.md modified +1 −1
- forge/actions.go modified +16 −0
- forge/api_test.go modified +6 −0
- forge/import.go modified +4 −0
- forge/store.go modified +10 −1
README.md modified
| @@ -19,7 +19,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS | ||
| 19 | 19 | - Branches, a branch switcher on every file view and a notice when a branch with new commits was pushed that links straight to a new pull request |
| 20 | 20 | - Markdown in pull requests, issues and comments with a preview tab and images you paste, drop or attach |
| 21 | 21 | - Pull requests from a branch or a fork with inline review, range diffs between force pushes and merge, squash or rebase without a working tree |
| 22 | -- Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push | |
| 22 | +- Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push, with at most six repositories per account or organisation unless an administrator creates them | |
| 23 | 23 | - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository |
| 24 | 24 | - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account |
| 25 | 25 | - Pull mirrors that fetch a source on a schedule and stay read only and push mirrors that copy every branch and tag to another host after each change |
forge/actions.go modified
| @@ -68,6 +68,13 @@ func (s *Server) materialise(repo Repo, build func(dir string) error) error { | ||
| 68 | 68 | return nil |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | +func (s *Server) repoRoom(u User, owner User) error { | |
| 72 | + if s.store.IsAdmin(u.ID) || s.store.RepoCount(owner.ID) < maxRepos { | |
| 73 | + return nil | |
| 74 | + } | |
| 75 | + return refuse(403, fmt.Sprintf("~%s already has %d repositories, the most an account may hold, delete one first", owner.Name, maxRepos)) | |
| 76 | +} | |
| 77 | + | |
| 71 | 78 | func (s *Server) createRepo(u User, ownerName, name, description string, vis int, tags string) (Repo, error) { |
| 72 | 79 | owner := u |
| 73 | 80 | if ownerName != "" && ownerName != u.Name { |
| @@ -77,6 +84,9 @@ func (s *Server) createRepo(u User, ownerName, name, description string, vis int | ||
| 77 | 84 | } |
| 78 | 85 | owner = org |
| 79 | 86 | } |
| 87 | + if err := s.repoRoom(u, owner); err != nil { | |
| 88 | + return Repo{}, err | |
| 89 | + } | |
| 80 | 90 | if s.uploads.blocked("create:" + u.Name) { |
| 81 | 91 | return Repo{}, refuse(429, "Too many new repositories, try again later") |
| 82 | 92 | } |
| @@ -95,6 +105,9 @@ func (s *Server) forkRepo(u User, repo Repo) (Repo, error) { | ||
| 95 | 105 | if s.shunned(repo, &u) { |
| 96 | 106 | return Repo{}, refuse(403, "You cannot fork this repository") |
| 97 | 107 | } |
| 108 | + if err := s.repoRoom(u, u); err != nil { | |
| 109 | + return Repo{}, err | |
| 110 | + } | |
| 98 | 111 | if s.uploads.blocked("create:" + u.Name) { |
| 99 | 112 | return Repo{}, refuse(429, "Too many new repositories, try again later") |
| 100 | 113 | } |
| @@ -144,6 +157,9 @@ func (s *Server) transferRepoTo(u User, repo Repo, owner string) (Repo, error) { | ||
| 144 | 157 | if target.ID == repo.Owner.ID { |
| 145 | 158 | return repo, nil |
| 146 | 159 | } |
| 160 | + if err := s.repoRoom(u, target); err != nil { | |
| 161 | + return repo, err | |
| 162 | + } | |
| 147 | 163 | if err := s.store.TransferRepo(repo.ID, target.ID); err != nil { |
| 148 | 164 | return repo, err |
| 149 | 165 | } |
forge/api_test.go modified
| @@ -3,6 +3,7 @@ package forge | ||
| 3 | 3 | import ( |
| 4 | 4 | "encoding/base64" |
| 5 | 5 | "encoding/json" |
| 6 | + "fmt" | |
| 6 | 7 | "net/http" |
| 7 | 8 | "net/http/httptest" |
| 8 | 9 | "net/url" |
| @@ -257,6 +258,11 @@ func TestAPI(t *testing.T) { | ||
| 257 | 258 | a.want(404, "GET", "/api/~alice/proj", "", "") |
| 258 | 259 | a.want(400, "DELETE", "/api/~alice/renamed", at, "") |
| 259 | 260 | a.want(204, "DELETE", "/api/~bob/proj?confirm=proj", bt, "") |
| 261 | + for i := s.store.RepoCount(bob.ID); i < maxRepos; i++ { | |
| 262 | + s.store.CreateRepo(bob, fmt.Sprintf("spare%d", i), "", Public, "") | |
| 263 | + } | |
| 264 | + a.want(403, "POST", "/api/repos", bt, `{"name":"seventh"}`) | |
| 265 | + a.want(403, "POST", "/api/~alice/renamed/forks", bt, "") | |
| 260 | 266 | |
| 261 | 267 | a.want(403, "GET", "/api/admin/stats", at, "") |
| 262 | 268 | s.store.SetAdmin("alice", true) |
forge/import.go modified
| @@ -176,6 +176,10 @@ func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) { | ||
| 176 | 176 | if name == "" { |
| 177 | 177 | name = importName(src) |
| 178 | 178 | } |
| 179 | + if err := s.repoRoom(*u, owner); err != nil { | |
| 180 | + fail(err.Error()) | |
| 181 | + return | |
| 182 | + } | |
| 179 | 183 | if s.uploads.blocked("import:" + u.Name) { |
| 180 | 184 | fail("Too many imports, try again later") |
| 181 | 185 | return |
forge/store.go modified
| @@ -1172,7 +1172,16 @@ func validEmail(email string) bool { | ||
| 1172 | 1172 | return err == nil && addr.Address == email && len(email) <= 200 && !strings.ContainsAny(email, "\r\n<>,;\"") |
| 1173 | 1173 | } |
| 1174 | 1174 | |
| 1175 | -const maxPins = 6 | |
| 1175 | +const ( | |
| 1176 | + maxPins = 6 | |
| 1177 | + maxRepos = 6 | |
| 1178 | +) | |
| 1179 | + | |
| 1180 | +func (s *Store) RepoCount(owner int64) int { | |
| 1181 | + var n int | |
| 1182 | + s.db.QueryRow(`select count(*) from repos where owner = ?`, owner).Scan(&n) | |
| 1183 | + return n | |
| 1184 | +} | |
| 1176 | 1185 | |
| 1177 | 1186 | func reservedRepo(name string) bool { |
| 1178 | 1187 | return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following" || name == "import" || name == "claim" || name == "gists" || name == "keys" || name == "members" |