Commit dfc6dd0641
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +1
| @@ -86,6 +86,7 @@ func newRoot() *cobra.Command { | ||
| 86 | 86 | pass("disable", "suspend an account: <username>", passOpts{server: []string{"admin", "user", "disable"}}), |
| 87 | 87 | pass("enable", "restore a suspended account: <username>", passOpts{server: []string{"admin", "user", "enable"}}), |
| 88 | 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 | 91 | group("email", "addresses on any account", |
| 91 | 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 | 161 | go notify.New(st, cfg, retryBase).Run(whCtx) |
| 162 | 162 | } |
| 163 | 163 | go mirror.New(st, cfg).Run(whCtx) |
| 164 | if d := cfg.Registration.PendingExpiryDuration(); d > 0 { | |
| 165 | go reapPending(whCtx, st, d) | |
| 166 | } | |
| 164 | 167 | go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL, |
| 165 | 168 | RepoDir: func(owner, name string) string { |
| 166 | 169 | return control.RepoDir(cfg.Server.Root, owner, name) |
| @@ -316,6 +319,7 @@ func adminCmd() *cobra.Command { | ||
| 316 | 319 | hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"), |
| 317 | 320 | hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"), |
| 318 | 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 | 324 | emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} |
| 321 | 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. | |
| 456 | func 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func 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 | 8 | "os" |
| 9 | 9 | "strconv" |
| 10 | 10 | "strings" |
| 11 | "time" | |
| 11 | 12 | |
| 12 | 13 | "github.com/BurntSushi/toml" |
| 13 | 14 | ) |
| @@ -81,6 +82,16 @@ type Web struct { | ||
| 81 | 82 | |
| 82 | 83 | type Registration struct { |
| 83 | 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. | |
| 92 | func (r Registration) PendingExpiryDuration() time.Duration { | |
| 93 | d, _ := time.ParseDuration(r.PendingExpiry) | |
| 94 | return d | |
| 84 | 95 | } |
| 85 | 96 | |
| 86 | 97 | // LFS stores large-file objects content-addressed under Root (default |
| @@ -131,6 +142,11 @@ type Limits struct { | ||
| 131 | 142 | // APIRate is sustained JSON-API requests per minute per caller; writes |
| 132 | 143 | // draw on a tenth of it. 0 uses the default. |
| 133 | 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 | |
| 136 | 152 | type Mail struct { |
| @@ -208,6 +224,14 @@ func (c Config) Validate() error { | ||
| 208 | 224 | if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil { |
| 209 | 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 | 235 | if c.SSH.Port < 1 || c.SSH.Port > 65535 { |
| 212 | 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 | 184 | PGPKeys []pgpOut `json:"pgp_keys"` |
| 185 | 185 | Orgs []orgOut `json:"orgs"` |
| 186 | 186 | Repos int64 `json:"repos"` |
| 187 | RepoLimit int64 `json:"repo_limit"` // 0 unlimited | |
| 188 | ByteLimit int64 `json:"byte_limit"` // 0 unlimited | |
| 187 | 189 | APITokens []tokenOut `json:"api_tokens"` |
| 188 | 190 | WebSessions int64 `json:"web_sessions"` |
| 189 | 191 | } |
| @@ -221,6 +223,8 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 221 | 223 | if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil { |
| 222 | 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 | 228 | tokens, err := c.Store.ListAPITokens(u.ID) |
| 225 | 229 | if err != nil { |
| 226 | 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 | 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 | 90 | // Scheme allowlist. file:// (and anything else local) would read the |
| 86 | 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 | 97 | if err := policy.ValidateName(name); err != nil { |
| 98 | 98 | return c.fail(protocol.ExitUsage, "%v", err) |
| 99 | 99 | } |
| 100 | if code := checkRepoQuota(c); code >= 0 { | |
| 101 | return code | |
| 102 | } | |
| 100 | 103 | id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility) |
| 101 | 104 | if err != nil { |
| 102 | 105 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/control/quota.go added +154
| @@ -0,0 +1,154 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 18 | func 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. | |
| 26 | func 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. | |
| 34 | func 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. | |
| 48 | type configLimits struct { | |
| 49 | MaxReposPerUser int | |
| 50 | MaxBytesPerUser int64 | |
| 51 | } | |
| 52 | ||
| 53 | // QuotaConfig is what sshd passes: the limits section of the config. | |
| 54 | func QuotaConfig(cfg config.Config) configLimits { | |
| 55 | return configLimits{cfg.Limits.MaxReposPerUser, cfg.Limits.MaxBytesPerUser} | |
| 56 | } | |
| 57 | ||
| 58 | func 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. | |
| 63 | func 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 | ||
| 78 | func 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 | ||
| 85 | func 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 | 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 | 195 | id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) |
| 191 | 196 | if err != nil { |
| 192 | 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 | 349 | hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), |
| 350 | 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 | 369 | return protocol.ExitFailure |
| 354 | 370 | } |
| 355 | 371 | return protocol.ExitOK |
internal/store/migrations/0031_user_limits.down.sql added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | ALTER TABLE users DROP COLUMN byte_limit; | |
| 2 | ALTER 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. | |
| 3 | ALTER TABLE users ADD COLUMN repo_limit INTEGER; | |
| 4 | ALTER TABLE users ADD COLUMN byte_limit INTEGER; | |
internal/store/quotas.go added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | // UserLimits is an account's quota overrides; nil means the configured | |
| 9 | // default applies. | |
| 10 | type UserLimits struct { | |
| 11 | Repos *int64 | |
| 12 | Bytes *int64 | |
| 13 | } | |
| 14 | ||
| 15 | func (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. | |
| 32 | func (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. | |
| 54 | func (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 | } | |