quotas per account, and expiry of accounts that never verified !174

merged merged by cmc on 2026-09-02 23:45 UTC · krz/gitbay:stack-8-quotas into main

13 files changed, +439 −1

Layout: unified · split

cmd/gitbay/main.go +1
@@ -86,6 +86,7 @@ func newRoot() *cobra.Command {
86 pass("disable", "suspend an account: <username>", passOpts{server: []string{"admin", "user", "disable"}}), 86 pass("disable", "suspend an account: <username>", passOpts{server: []string{"admin", "user", "disable"}}),
87 pass("enable", "restore a suspended account: <username>", passOpts{server: []string{"admin", "user", "enable"}}), 87 pass("enable", "restore a suspended account: <username>", passOpts{server: []string{"admin", "user", "enable"}}),
88 pass("delete", "delete an account that anchors nothing: <username> --yes", passOpts{server: []string{"admin", "user", "delete"}}), 88 pass("delete", "delete an account that anchors nothing: <username> --yes", passOpts{server: []string{"admin", "user", "delete"}}),
89 pass("limits", "show or set repository and storage caps: <username> [--repos n|default] [--bytes n|default]", passOpts{server: []string{"admin", "user", "limits"}}),
89 ), 90 ),
90 group("email", "addresses on any account", 91 group("email", "addresses on any account",
91 pass("verify", "mark an address verified by admin assertion: <username> <address>", passOpts{server: []string{"admin", "email", "verify"}}), 92 pass("verify", "mark an address verified by admin assertion: <username> <address>", passOpts{server: []string{"admin", "email", "verify"}}),
cmd/gitbayd/main.go +30
@@ -161,6 +161,9 @@ func serveCmd() *cobra.Command {
161 go notify.New(st, cfg, retryBase).Run(whCtx) 161 go notify.New(st, cfg, retryBase).Run(whCtx)
162 } 162 }
163 go mirror.New(st, cfg).Run(whCtx) 163 go mirror.New(st, cfg).Run(whCtx)
164 if d := cfg.Registration.PendingExpiryDuration(); d > 0 {
165 go reapPending(whCtx, st, d)
166 }
164 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL, 167 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
165 RepoDir: func(owner, name string) string { 168 RepoDir: func(owner, name string) string {
166 return control.RepoDir(cfg.Server.Root, owner, name) 169 return control.RepoDir(cfg.Server.Root, owner, name)
@@ -316,6 +319,7 @@ func adminCmd() *cobra.Command {
316 hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"), 319 hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"),
317 hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"), 320 hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"),
318 hostCmd("demote <username>", "remove instance admin from an account (never the last one)", "admin", "user", "demote"), 321 hostCmd("demote <username>", "remove instance admin from an account (never the last one)", "admin", "user", "demote"),
322 hostCmd("limits <username> [--repos n|default] [--bytes n|default]", "show or set repository and storage caps", "admin", "user", "limits"),
319 ) 323 )
320 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} 324 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
321 emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify")) 325 emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify"))
@@ -445,3 +449,29 @@ func hostUserCreateCmd() *cobra.Command {
445 }, 449 },
446 } 450 }
447} 451}
452
453// reapPending removes self-registered accounts still unverified after
454// maxAge, hourly and once at start. GITBAY_REAP_TICK shortens the
455// interval for tests.
456func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) {
457 tick := time.Hour
458 if v := os.Getenv("GITBAY_REAP_TICK"); v != "" {
459 if d, err := time.ParseDuration(v); err == nil {
460 tick = d
461 }
462 }
463 t := time.NewTicker(tick)
464 defer t.Stop()
465 for {
466 if removed, err := st.ReapPendingUsers(maxAge); err != nil {
467 slog.Error("reaping pending accounts", "err", err)
468 } else if len(removed) > 0 {
469 slog.Info("removed unverified accounts", "users", removed)
470 }
471 select {
472 case <-ctx.Done():
473 return
474 case <-t.C:
475 }
476 }
477}
e2e/quota_test.go added +108
@@ -0,0 +1,108 @@
1package e2e
2
3import (
4 "crypto/rand"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11)
12
13// Per-account caps on repositories and storage, with the admin override,
14// and expiry of accounts that never verified.
15func TestQuotasAndPendingExpiry(t *testing.T) {
16 t.Setenv("GITBAY_REAP_TICK", "500ms")
17 smtp := startFakeSMTP(t)
18 inst := startInstanceWith(t, fmt.Sprintf(
19 "[registration]\nmode = \"open\"\npending_expiry = \"2s\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n"+
20 "[limits]\nmax_repos_per_user = 2\nmax_bytes_per_user = 300000\n", smtp.addr))
21 rootKey := inst.newKey(t, "root")
22 aliceKey := inst.newKey(t, "alice")
23 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
24 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
25
26 // Two repositories fit; the third is refused with the numbers.
27 for _, r := range []string{"alice/one", "alice/two"} {
28 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", r); code != 0 {
29 t.Fatalf("create %s: %s", r, errOut)
30 }
31 }
32 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/three"); code != 4 || !strings.Contains(errOut, "2 of the 2 repositories") {
33 t.Fatalf("third repo: exit %d %s", code, errOut)
34 }
35 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "fork", "alice/one", "--name", "onefork"); code != 4 {
36 t.Fatal("fork slipped past the cap")
37 }
38 // An org is not capped.
39 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 {
40 t.Fatal("org create failed")
41 }
42 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib"); code != 0 {
43 t.Fatalf("org repo: %s", errOut)
44 }
45 // The admin raises the cap for this account; the third fits.
46 if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "3"); code != 0 || !strings.Contains(out, "repos 2 of 3") {
47 t.Fatalf("limits: exit %d %s", code, out)
48 }
49 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/three"); code != 0 {
50 t.Fatalf("third repo after raise: %s", errOut)
51 }
52 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "show", "alice", "--json"); !strings.Contains(out, `"repo_limit":3`) || !strings.Contains(out, `"byte_limit":300000`) {
53 t.Fatalf("show lacks limits:\n%s", out)
54 }
55 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "default"); !strings.Contains(out, "of 2") {
56 t.Fatalf("limits back to default:\n%s", out)
57 }
58
59 // Storage: a push past what the account has left is refused.
60 work := t.TempDir()
61 env := inst.gitEnv(aliceKey)
62 mustGit(t, work, env, "clone", inst.sshURL("alice/one"), "w")
63 dir := filepath.Join(work, "w")
64 os.WriteFile(filepath.Join(dir, "small.txt"), []byte("ok\n"), 0o644)
65 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
66 mustGit(t, dir, env, "add", ".")
67 mustGit(t, dir, env, "commit", "-q", "-m", "small")
68 mustGit(t, dir, env, "push", "-q", "origin", "main")
69 big := make([]byte, 400_000)
70 rand.Read(big)
71 os.WriteFile(filepath.Join(dir, "big.bin"), big, 0o644)
72 mustGit(t, dir, env, "add", ".")
73 mustGit(t, dir, env, "commit", "-q", "-m", "big")
74 if out, code := gitRun(t, dir, env, "push", "origin", "main"); code == 0 || !strings.Contains(out, "max") {
75 t.Fatalf("push past the storage cap accepted: exit %d\n%s", code, out)
76 }
77 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--bytes", "0"); code != 0 {
78 t.Fatal("lift byte cap failed")
79 }
80 mustGit(t, dir, env, "push", "-q", "origin", "main")
81
82 // An account that registers and never verifies is removed after
83 // pending_expiry; the name is free again.
84 newKey := inst.newKey(t, "dana")
85 if _, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test"); code != 0 {
86 t.Fatalf("register: %s", errOut)
87 }
88 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "list", "--state", "pending"); !strings.HasPrefix(out, "dana\t") {
89 t.Fatalf("dana not pending:\n%s", out)
90 }
91 deadline := time.Now().Add(15 * time.Second)
92 for {
93 out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "list", "--state", "pending")
94 if strings.TrimSpace(out) == "" {
95 break
96 }
97 if time.Now().After(deadline) {
98 t.Fatalf("pending account never expired:\n%s", out)
99 }
100 time.Sleep(300 * time.Millisecond)
101 }
102 if _, _, code := inst.ssh(t, newKey, "", "whoami"); code == 0 {
103 t.Fatal("expired account still authenticates")
104 }
105 if out := inst.admin(t, "admin", "audit", "--action", "pending.expired"); !strings.Contains(out, `"user":"dana"`) {
106 t.Fatalf("expiry not audited:\n%s", out)
107 }
108}
internal/config/config.go +24
@@ -8,6 +8,7 @@ import (
8 "os" 8 "os"
9 "strconv" 9 "strconv"
10 "strings" 10 "strings"
11 "time"
11 12
12 "github.com/BurntSushi/toml" 13 "github.com/BurntSushi/toml"
13) 14)
@@ -81,6 +82,16 @@ type Web struct {
81 82
82type Registration struct { 83type Registration struct {
83 Mode string `toml:"mode"` // closed | invite | open 84 Mode string `toml:"mode"` // closed | invite | open
85 // PendingExpiry is how long a self-registered account may stay
86 // unverified before it is removed, as a duration ("168h"). Empty
87 // keeps such accounts forever.
88 PendingExpiry string `toml:"pending_expiry"`
89}
90
91// PendingExpiryDuration parses PendingExpiry; zero means never.
92func (r Registration) PendingExpiryDuration() time.Duration {
93 d, _ := time.ParseDuration(r.PendingExpiry)
94 return d
84} 95}
85 96
86// LFS stores large-file objects content-addressed under Root (default 97// LFS stores large-file objects content-addressed under Root (default
@@ -131,6 +142,11 @@ type Limits struct {
131 // APIRate is sustained JSON-API requests per minute per caller; writes 142 // APIRate is sustained JSON-API requests per minute per caller; writes
132 // draw on a tenth of it. 0 uses the default. 143 // draw on a tenth of it. 0 uses the default.
133 APIRate int `toml:"api_rate"` 144 APIRate int `toml:"api_rate"`
145 // Per-account quotas on what a user owns directly (organizations are
146 // not capped). 0 means unlimited; admin user limits overrides per
147 // account.
148 MaxReposPerUser int `toml:"max_repos_per_user"`
149 MaxBytesPerUser int64 `toml:"max_bytes_per_user"`
134} 150}
135 151
136type Mail struct { 152type Mail struct {
@@ -208,6 +224,14 @@ func (c Config) Validate() error {
208 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil { 224 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
209 errs = append(errs, err) 225 errs = append(errs, err)
210 } 226 }
227 if c.Registration.PendingExpiry != "" {
228 if d, err := time.ParseDuration(c.Registration.PendingExpiry); err != nil || d <= 0 {
229 errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry))
230 }
231 }
232 if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 {
233 errs = append(errs, errors.New("limits.max_repos_per_user and max_bytes_per_user must not be negative"))
234 }
211 if c.SSH.Port < 1 || c.SSH.Port > 65535 { 235 if c.SSH.Port < 1 || c.SSH.Port > 65535 {
212 errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port)) 236 errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
213 } 237 }
internal/control/admin.go +4
@@ -184,6 +184,8 @@ func runAdminUserShow(c *Ctx, args []string) int {
184 PGPKeys []pgpOut `json:"pgp_keys"` 184 PGPKeys []pgpOut `json:"pgp_keys"`
185 Orgs []orgOut `json:"orgs"` 185 Orgs []orgOut `json:"orgs"`
186 Repos int64 `json:"repos"` 186 Repos int64 `json:"repos"`
187 RepoLimit int64 `json:"repo_limit"` // 0 unlimited
188 ByteLimit int64 `json:"byte_limit"` // 0 unlimited
187 APITokens []tokenOut `json:"api_tokens"` 189 APITokens []tokenOut `json:"api_tokens"`
188 WebSessions int64 `json:"web_sessions"` 190 WebSessions int64 `json:"web_sessions"`
189 } 191 }
@@ -221,6 +223,8 @@ func runAdminUserShow(c *Ctx, args []string) int {
221 if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil { 223 if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
222 return c.fail(protocol.ExitFailure, "%v", err) 224 return c.fail(protocol.ExitFailure, "%v", err)
223 } 225 }
226 d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID)
227 d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID)
224 tokens, err := c.Store.ListAPITokens(u.ID) 228 tokens, err := c.Store.ListAPITokens(u.ID)
225 if err != nil { 229 if err != nil {
226 return c.fail(protocol.ExitFailure, "%v", err) 230 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/import.go +5
@@ -81,6 +81,11 @@ func runRepoImport(c *Ctx, args []string) int {
81 } 81 }
82 ownerKind, ownerID = "org", org.ID 82 ownerKind, ownerID = "org", org.ID
83 } 83 }
84 if ownerKind == "user" {
85 if code := checkRepoQuota(c); code >= 0 {
86 return code
87 }
88 }
84 89
85 // Scheme allowlist. file:// (and anything else local) would read the 90 // Scheme allowlist. file:// (and anything else local) would read the
86 // server's filesystem; ssh:// would use the server's own keys. 91 // server's filesystem; ssh:// would use the server's own keys.
internal/control/mr.go +3
@@ -97,6 +97,9 @@ func runRepoFork(c *Ctx, args []string) int {
97 if err := policy.ValidateName(name); err != nil { 97 if err := policy.ValidateName(name); err != nil {
98 return c.fail(protocol.ExitUsage, "%v", err) 98 return c.fail(protocol.ExitUsage, "%v", err)
99 } 99 }
100 if code := checkRepoQuota(c); code >= 0 {
101 return code
102 }
100 id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility) 103 id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
101 if err != nil { 104 if err != nil {
102 return c.fail(protocol.ExitFailure, "%v", err) 105 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/quota.go added +154
@@ -0,0 +1,154 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "strconv"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/gitutil"
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// Quotas cap what one account owns directly. The limit is the account's
15// override when set, else the configured default; 0 is unlimited.
16
17// RepoLimit is the account's repository cap, 0 for none.
18func RepoLimit(st *store.Store, cfg configLimits, userID int64) int64 {
19 if l, err := st.UserLimits(userID); err == nil && l.Repos != nil {
20 return *l.Repos
21 }
22 return int64(cfg.MaxReposPerUser)
23}
24
25// ByteLimit is the account's storage cap in bytes, 0 for none.
26func ByteLimit(st *store.Store, cfg configLimits, userID int64) int64 {
27 if l, err := st.UserLimits(userID); err == nil && l.Bytes != nil {
28 return *l.Bytes
29 }
30 return cfg.MaxBytesPerUser
31}
32
33// OwnedBytes is the disk taken by the repositories a user owns directly.
34func OwnedBytes(st *store.Store, root string, userID int64) int64 {
35 repos, err := st.ListReposForOwner("user", userID)
36 if err != nil {
37 return 0
38 }
39 var total int64
40 for _, r := range repos {
41 total += gitutil.DirSize(RepoDir(root, r.OwnerName, r.Name))
42 }
43 return total
44}
45
46// configLimits is the slice of config the quota functions read, so the
47// sshd package can pass its Limits without importing control's Ctx.
48type configLimits struct {
49 MaxReposPerUser int
50 MaxBytesPerUser int64
51}
52
53// QuotaConfig is what sshd passes: the limits section of the config.
54func QuotaConfig(cfg config.Config) configLimits {
55 return configLimits{cfg.Limits.MaxReposPerUser, cfg.Limits.MaxBytesPerUser}
56}
57
58func limitsOf(c *Ctx) configLimits {
59 return configLimits{c.Cfg.Limits.MaxReposPerUser, c.Cfg.Limits.MaxBytesPerUser}
60}
61
62// checkRepoQuota refuses a new user-owned repository past the cap.
63func checkRepoQuota(c *Ctx) int {
64 limit := RepoLimit(c.Store, limitsOf(c), c.User.ID)
65 if limit == 0 {
66 return -1
67 }
68 n, err := c.Store.OwnedRepoCount(c.User.ID)
69 if err != nil {
70 return c.fail(protocol.ExitFailure, "%v", err)
71 }
72 if n >= limit {
73 return c.fail(protocol.ExitDenied, "you own %d of the %d repositories your account may hold; delete or transfer one, or ask an admin to raise the limit", n, limit)
74 }
75 return -1
76}
77
78func init() {
79 register(Command{Path: []string{"admin", "user", "limits"},
80 Summary: "show or set an account's repository and storage caps (instance admins)",
81 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]",
82 SSHOnly: true, Run: runAdminUserLimits})
83}
84
85func runAdminUserLimits(c *Ctx, args []string) int {
86 if code := requireInstanceAdmin(c); code >= 0 {
87 return code
88 }
89 if len(args) < 1 {
90 return c.fail(protocol.ExitUsage, "usage: admin user limits <username> [--repos <n>|default] [--bytes <n>|default]")
91 }
92 u, err := c.Store.UserByUsername(args[0])
93 if err != nil {
94 return c.fail(protocol.ExitNotFound, "no user %q", args[0])
95 }
96 l, err := c.Store.UserLimits(u.ID)
97 if err != nil {
98 return c.fail(protocol.ExitFailure, "%v", err)
99 }
100 set := false
101 for i := 1; i < len(args); i++ {
102 if i+1 >= len(args) {
103 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
104 }
105 v := args[i+1]
106 var target **int64
107 switch args[i] {
108 case "--repos":
109 target = &l.Repos
110 case "--bytes":
111 target = &l.Bytes
112 default:
113 return c.fail(protocol.ExitUsage, "usage: admin user limits <username> [--repos <n>|default] [--bytes <n>|default]")
114 }
115 if v == "default" {
116 *target = nil
117 } else {
118 n, err := strconv.ParseInt(v, 10, 64)
119 if err != nil || n < 0 {
120 return c.fail(protocol.ExitUsage, "%s takes a non-negative number or default", args[i])
121 }
122 *target = &n
123 }
124 set = true
125 i++
126 }
127 if set {
128 if err := c.Store.SetUserLimits(u.ID, l); err != nil {
129 return c.fail(protocol.ExitFailure, "%v", err)
130 }
131 c.Store.Audit(c.User.ID, "admin user.limits", map[string]any{"user": u.Username, "repos": l.Repos, "bytes": l.Bytes})
132 }
133 type out struct {
134 User string `json:"user"`
135 Repos int64 `json:"repos"` // effective cap, 0 unlimited
136 Bytes int64 `json:"bytes"` // effective cap, 0 unlimited
137 ReposOwned int64 `json:"repos_owned"`
138 BytesOwned int64 `json:"bytes_owned"`
139 Override bool `json:"override"` // any per-account value set
140 }
141 d := out{User: u.Username, Repos: RepoLimit(c.Store, limitsOf(c), u.ID), Bytes: ByteLimit(c.Store, limitsOf(c), u.ID),
142 Override: l.Repos != nil || l.Bytes != nil}
143 d.ReposOwned, _ = c.Store.OwnedRepoCount(u.ID)
144 d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, u.ID)
145 return c.emit(d, func(w io.Writer) {
146 cap := func(n int64) string {
147 if n == 0 {
148 return "unlimited"
149 }
150 return strconv.FormatInt(n, 10)
151 }
152 fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\n", d.User, d.ReposOwned, cap(d.Repos), d.BytesOwned, cap(d.Bytes))
153 })
154}
internal/control/repo.go +5
@@ -187,6 +187,11 @@ func runRepoCreate(c *Ctx, args []string) int {
187 } 187 }
188 ownerKind, ownerID = "org", org.ID 188 ownerKind, ownerID = "org", org.ID
189 } 189 }
190 if ownerKind == "user" {
191 if code := checkRepoQuota(c); code >= 0 {
192 return code
193 }
194 }
190 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) 195 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
191 if err != nil { 196 if err != nil {
192 return c.fail(protocol.ExitFailure, "%v", err) 197 return c.fail(protocol.ExitFailure, "%v", err)
internal/sshd/sshd.go +17 −1
@@ -349,7 +349,23 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
349 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), 349 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
350 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10), 350 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
351 } 351 }
352 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, cfg.Limits.MaxPackBytes); err != nil { 352 // A storage quota on the owner rides the same mechanism as the pack
353 // cap: the pack may be no larger than what the owner has left.
354 maxPack := cfg.Limits.MaxPackBytes
355 if write && repo.OwnerKind == "user" {
356 if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 {
357 used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID)
358 left := limit - used
359 if left <= 0 {
360 fmt.Fprintf(stderr, "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit\n", repo.OwnerName, used, limit)
361 return protocol.ExitDenied
362 }
363 if maxPack == 0 || left < maxPack {
364 maxPack = left
365 }
366 }
367 }
368 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack); err != nil {
353 return protocol.ExitFailure 369 return protocol.ExitFailure
354 } 370 }
355 return protocol.ExitOK 371 return protocol.ExitOK
internal/store/migrations/0031_user_limits.down.sql added +2
@@ -0,0 +1,2 @@
1ALTER TABLE users DROP COLUMN byte_limit;
2ALTER TABLE users DROP COLUMN repo_limit;
internal/store/migrations/0031_user_limits.up.sql added +4
@@ -0,0 +1,4 @@
1-- Per-account overrides of limits.max_repos_per_user and
2-- max_bytes_per_user. NULL means the configured default.
3ALTER TABLE users ADD COLUMN repo_limit INTEGER;
4ALTER TABLE users ADD COLUMN byte_limit INTEGER;
internal/store/quotas.go added +82
@@ -0,0 +1,82 @@
1package store
2
3import (
4 "database/sql"
5 "time"
6)
7
8// UserLimits is an account's quota overrides; nil means the configured
9// default applies.
10type UserLimits struct {
11 Repos *int64
12 Bytes *int64
13}
14
15func (s *Store) UserLimits(userID int64) (UserLimits, error) {
16 var repos, bytes sql.NullInt64
17 err := s.DB.QueryRow("SELECT repo_limit, byte_limit FROM users WHERE id = ?", userID).Scan(&repos, &bytes)
18 if err != nil {
19 return UserLimits{}, err
20 }
21 var l UserLimits
22 if repos.Valid {
23 l.Repos = &repos.Int64
24 }
25 if bytes.Valid {
26 l.Bytes = &bytes.Int64
27 }
28 return l, nil
29}
30
31// SetUserLimits writes the overrides; a nil field clears back to default.
32func (s *Store) SetUserLimits(userID int64, l UserLimits) error {
33 var repos, bytes any
34 if l.Repos != nil {
35 repos = *l.Repos
36 }
37 if l.Bytes != nil {
38 bytes = *l.Bytes
39 }
40 res, err := s.DB.Exec("UPDATE users SET repo_limit = ?, byte_limit = ? WHERE id = ?", repos, bytes, userID)
41 if err != nil {
42 return err
43 }
44 if n, _ := res.RowsAffected(); n == 0 {
45 return ErrNotFound
46 }
47 return nil
48}
49
50// ReapPendingUsers deletes self-registered accounts still unverified
51// after maxAge. A pending account owns nothing (it cannot create a
52// repository before verifying), so DeleteUser has nothing to refuse; an
53// account that somehow anchors content is left alone and reported.
54func (s *Store) ReapPendingUsers(maxAge time.Duration) ([]string, error) {
55 cutoff := fmtTime(time.Now().Add(-maxAge))
56 rows, err := s.DB.Query("SELECT id, username FROM users WHERE pending = 1 AND created_at < ?", cutoff)
57 if err != nil {
58 return nil, err
59 }
60 type row struct {
61 id int64
62 name string
63 }
64 var stale []row
65 for rows.Next() {
66 var r row
67 if err := rows.Scan(&r.id, &r.name); err != nil {
68 rows.Close()
69 return nil, err
70 }
71 stale = append(stale, r)
72 }
73 rows.Close()
74 var removed []string
75 for _, r := range stale {
76 if err := s.DeleteUser(r.id); err == nil {
77 removed = append(removed, r.name)
78 s.Audit(0, "pending.expired", map[string]any{"user": r.name})
79 }
80 }
81 return removed, nil
82}