~gitpocket/gitpocket

8

feat(repos): cap accounts and organisations at six repositories

commit
161949a0fab0794bbb7e894db46ad9ede8bc5ce2 browse tree
parent
94889e856cd3784c07f08335a6fa359ea3f1e7a7
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 15:26 UTC

5 files changed, +37 −2

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
1919 - 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
2020 - Markdown in pull requests, issues and comments with a preview tab and images you paste, drop or attach
2121 - 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
2323 - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository
2424 - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account
2525 - 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 {
6868 return nil
6969 }
7070
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+
7178 func (s *Server) createRepo(u User, ownerName, name, description string, vis int, tags string) (Repo, error) {
7279 owner := u
7380 if ownerName != "" && ownerName != u.Name {
@@ -77,6 +84,9 @@ func (s *Server) createRepo(u User, ownerName, name, description string, vis int
7784 }
7885 owner = org
7986 }
87+ if err := s.repoRoom(u, owner); err != nil {
88+ return Repo{}, err
89+ }
8090 if s.uploads.blocked("create:" + u.Name) {
8191 return Repo{}, refuse(429, "Too many new repositories, try again later")
8292 }
@@ -95,6 +105,9 @@ func (s *Server) forkRepo(u User, repo Repo) (Repo, error) {
95105 if s.shunned(repo, &u) {
96106 return Repo{}, refuse(403, "You cannot fork this repository")
97107 }
108+ if err := s.repoRoom(u, u); err != nil {
109+ return Repo{}, err
110+ }
98111 if s.uploads.blocked("create:" + u.Name) {
99112 return Repo{}, refuse(429, "Too many new repositories, try again later")
100113 }
@@ -144,6 +157,9 @@ func (s *Server) transferRepoTo(u User, repo Repo, owner string) (Repo, error) {
144157 if target.ID == repo.Owner.ID {
145158 return repo, nil
146159 }
160+ if err := s.repoRoom(u, target); err != nil {
161+ return repo, err
162+ }
147163 if err := s.store.TransferRepo(repo.ID, target.ID); err != nil {
148164 return repo, err
149165 }

forge/api_test.go modified

@@ -3,6 +3,7 @@ package forge
33 import (
44 "encoding/base64"
55 "encoding/json"
6+ "fmt"
67 "net/http"
78 "net/http/httptest"
89 "net/url"
@@ -257,6 +258,11 @@ func TestAPI(t *testing.T) {
257258 a.want(404, "GET", "/api/~alice/proj", "", "")
258259 a.want(400, "DELETE", "/api/~alice/renamed", at, "")
259260 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, "")
260266
261267 a.want(403, "GET", "/api/admin/stats", at, "")
262268 s.store.SetAdmin("alice", true)

forge/import.go modified

@@ -176,6 +176,10 @@ func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) {
176176 if name == "" {
177177 name = importName(src)
178178 }
179+ if err := s.repoRoom(*u, owner); err != nil {
180+ fail(err.Error())
181+ return
182+ }
179183 if s.uploads.blocked("import:" + u.Name) {
180184 fail("Too many imports, try again later")
181185 return

forge/store.go modified

@@ -1172,7 +1172,16 @@ func validEmail(email string) bool {
11721172 return err == nil && addr.Address == email && len(email) <= 200 && !strings.ContainsAny(email, "\r\n<>,;\"")
11731173 }
11741174
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+}
11761185
11771186 func reservedRepo(name string) bool {
11781187 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"