Commit dfc6dd0641

dfc6dd064134f5b30eacf634fb8e684835848fb1

parent: 1941ec0174

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-02 04:37 UTC

quotas per account, and expiry of accounts that never verified

The limits section capped pack, blob and asset sizes but nothing per
account, and a self-registered account that never verified stayed
forever. The Roadmap named quotas as a precondition for opening the
instance.

limits.max_repos_per_user caps the repositories an account owns
directly; repo create, fork and import refuse past it with the numbers.
limits.max_bytes_per_user caps their disk: a push may be no larger
than what the owner has left, riding the same receive.maxInputSize as
the pack cap, and is refused outright when nothing is left.
Organizations are not capped. Migration 0031 adds per-account
overrides, set with admin user limits <name> [--repos n|default]
[--bytes n|default] and shown by admin user show.

registration.pending_expiry, a duration, removes accounts still
unverified after that long, hourly and once at start, audited as
pending.expired. Empty keeps them forever, as before.

Closes #82

Layout: unified · split

cmd/gitbay/main.go +1
@@ -86,6 +86,7 @@ func newRoot() *cobra.Command {
8686 pass("disable", "suspend an account: <username>", passOpts{server: []string{"admin", "user", "disable"}}),
8787 pass("enable", "restore a suspended account: <username>", passOpts{server: []string{"admin", "user", "enable"}}),
8888 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"}}),
8990 ),
9091 group("email", "addresses on any account",
9192 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 {
161161 go notify.New(st, cfg, retryBase).Run(whCtx)
162162 }
163163 go mirror.New(st, cfg).Run(whCtx)
164 if d := cfg.Registration.PendingExpiryDuration(); d > 0 {
165 go reapPending(whCtx, st, d)
166 }
164167 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
165168 RepoDir: func(owner, name string) string {
166169 return control.RepoDir(cfg.Server.Root, owner, name)
@@ -316,6 +319,7 @@ func adminCmd() *cobra.Command {
316319 hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"),
317320 hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"),
318321 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"),
319323 )
320324 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
321325 emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify"))
@@ -445,3 +449,29 @@ func hostUserCreateCmd() *cobra.Command {
445449 },
446450 }
447451}
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 (
88 "os"
99 "strconv"
1010 "strings"
11 "time"
1112
1213 "github.com/BurntSushi/toml"
1314)
@@ -81,6 +82,16 @@ type Web struct {
8182
8283type Registration struct {
8384 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
8495}
8596
8697// LFS stores large-file objects content-addressed under Root (default
@@ -131,6 +142,11 @@ type Limits struct {
131142 // APIRate is sustained JSON-API requests per minute per caller; writes
132143 // draw on a tenth of it. 0 uses the default.
133144 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"`
134150}
135151
136152type Mail struct {
@@ -208,6 +224,14 @@ func (c Config) Validate() error {
208224 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
209225 errs = append(errs, err)
210226 }
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 }
211235 if c.SSH.Port < 1 || c.SSH.Port > 65535 {
212236 errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
213237 }
internal/control/admin.go +4
@@ -184,6 +184,8 @@ func runAdminUserShow(c *Ctx, args []string) int {
184184 PGPKeys []pgpOut `json:"pgp_keys"`
185185 Orgs []orgOut `json:"orgs"`
186186 Repos int64 `json:"repos"`
187 RepoLimit int64 `json:"repo_limit"` // 0 unlimited
188 ByteLimit int64 `json:"byte_limit"` // 0 unlimited
187189 APITokens []tokenOut `json:"api_tokens"`
188190 WebSessions int64 `json:"web_sessions"`
189191 }
@@ -221,6 +223,8 @@ func runAdminUserShow(c *Ctx, args []string) int {
221223 if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
222224 return c.fail(protocol.ExitFailure, "%v", err)
223225 }
226 d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID)
227 d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID)
224228 tokens, err := c.Store.ListAPITokens(u.ID)
225229 if err != nil {
226230 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/import.go +5
@@ -81,6 +81,11 @@ func runRepoImport(c *Ctx, args []string) int {
8181 }
8282 ownerKind, ownerID = "org", org.ID
8383 }
84 if ownerKind == "user" {
85 if code := checkRepoQuota(c); code >= 0 {
86 return code
87 }
88 }
8489
8590 // Scheme allowlist. file:// (and anything else local) would read the
8691 // 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 {
9797 if err := policy.ValidateName(name); err != nil {
9898 return c.fail(protocol.ExitUsage, "%v", err)
9999 }
100 if code := checkRepoQuota(c); code >= 0 {
101 return code
102 }
100103 id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
101104 if err != nil {
102105 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 {
187187 }
188188 ownerKind, ownerID = "org", org.ID
189189 }
190 if ownerKind == "user" {
191 if code := checkRepoQuota(c); code >= 0 {
192 return code
193 }
194 }
190195 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
191196 if err != nil {
192197 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
349349 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
350350 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
351351 }
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 {
353369 return protocol.ExitFailure
354370 }
355371 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}