Commit ebf1e30fe0
ebf1e30fe0ae09b2d5f93705c1cbf7f513b6ed22
parent: aa03e8c8e0
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-12 01:25 UTC
control, config: cap snippets per account
max_snippets_per_user (0, unlimited) mirrors max_repos_per_user:
runSnippetCreate refuses a create at or past the cap with exit 2,
admins included. Also renames profile.go's shadowing "all" bool to
seeAll, corrects a store comment that claimed every ListSnippets
caller pages at 200 rows (the web list page does not), and adds a
control-level test for the existing 65th-file refusal on snippet file
set.
Ref #195
.gitbay/wiki/Admin.org
+1
| @@ -115,6 +115,7 @@ default, is right when gitbayd terminates TLS itself. |
| 115 | 115 | - =max_blob_bytes= (100MB) — cap on raw file serving over the web. |
| 116 | 116 | - =max_asset_bytes= (512MB) — cap per uploaded release asset. |
| 117 | 117 | - =max_snippet_bytes= (1MB) — cap per snippet file. |
| 118 | - =max_snippets_per_user= (0, unlimited) — snippets an account may own. |
| 118 | 119 | - =max_repos_per_user= (0, unlimited) — repositories an account may own |
| 119 | 120 | directly; =repo create=, =fork= and =import= refuse past it. |
| 120 | 121 | Organizations are not capped. |
CHANGELOG.org
+4 −2
| @@ -13,8 +13,10 @@ shared by URL and edited in place. |
| 13 | 13 | - =snippet create|show|list|edit|delete= and =snippet file |
| 14 | 14 | set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes= |
| 15 | 15 | (1MB), at most 64 per snippet; a snippet keeps at least one. |
| 16 | | Visibility =public=, =unlisted= (default) or =private=; a private |
| 17 | | snippet is not found to everyone but its owner and admins. |
| 16 | =limits.max_snippets_per_user= (0, unlimited) caps how many an |
| 17 | account may own, admins included. Visibility =public=, =unlisted= |
| 18 | (default) or =private=; a private snippet is not found to everyone |
| 19 | but its owner and admins. |
| 18 | 20 | - Web: =/<owner>/-/snippets= lists, each snippet page renders its |
| 19 | 21 | files with a raw route per file, and the owner creates, edits and |
| 20 | 22 | deletes from the page through the same commands. The owner page |
docs/specs/2026-09-11-snippets-design.md
+3
| @@ -94,6 +94,9 @@ Limits: |
| 94 | 94 | 1 MiB. Enforced on create and `file set` with the same |
| 95 | 95 | `io.LimitReader(n+1)` shape as `release asset add`. |
| 96 | 96 | - 64 files per snippet, a constant in `internal/control/snippet.go`. |
| 97 | - `limits.max_snippets_per_user`, default unlimited, like |
| 98 | `max_repos_per_user`. Enforced on `snippet create`; admins are not |
| 99 | exempt. |
| 97 | 100 | - Snippet bytes do not count toward `max_bytes_per_user`; that quota |
| 98 | 101 | measures repositories and LFS. |
| 99 | 102 | |
e2e/snippet_test.go
+6 −1
| @@ -10,7 +10,7 @@ import ( |
| 10 | 10 | // Snippets over SSH: create from stdin, read back, list by visibility, |
| 11 | 11 | // edit files and metadata, and the not-found rule for private ones. |
| 12 | 12 | func TestSnippets(t *testing.T) { |
| 13 | | inst := startInstance(t) |
| 13 | inst := startInstanceWith(t, "[limits]\nmax_snippets_per_user = 3\n") |
| 14 | 14 | aliceKey := inst.newKey(t, "alice") |
| 15 | 15 | bobKey := inst.newKey(t, "bob") |
| 16 | 16 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") |
| @@ -61,6 +61,11 @@ func TestSnippets(t *testing.T) { |
| 61 | 61 | public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json")) |
| 62 | 62 | private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json")) |
| 63 | 63 | |
| 64 | // The per-account cap: three snippets exist, a fourth is refused. |
| 65 | if msg := fails(aliceKey, "x\n", 2, "snippet", "create", "c.txt"); !strings.Contains(msg, "snippet limit reached") { |
| 66 | t.Fatalf("cap refusal: %s", msg) |
| 67 | } |
| 68 | |
| 64 | 69 | // Refusals on create: empty, not text, over the limit, bad name. |
| 65 | 70 | fails(aliceKey, "", 2, "snippet", "create", "x.txt") |
| 66 | 71 | fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin") |
internal/config/config.go
+3
| @@ -177,6 +177,9 @@ type Limits struct { |
| 177 | 177 | MaxBlobBytes int64 `toml:"max_blob_bytes"` |
| 178 | 178 | MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset |
| 179 | 179 | MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file |
| 180 | // MaxSnippetsPerUser caps snippets an account may own. 0 means |
| 181 | // unlimited, like MaxReposPerUser. |
| 182 | MaxSnippetsPerUser int `toml:"max_snippets_per_user"` |
| 180 | 183 | CloneTimeoutSec int `toml:"clone_timeout"` |
| 181 | 184 | SSHAuthRate int `toml:"ssh_auth_rate"` |
| 182 | 185 | // APIRate is sustained JSON-API requests per minute per caller; writes |
internal/control/profile.go
+2 −2
| @@ -327,8 +327,8 @@ func runProfileShow(c *Ctx, args []string) int { |
| 327 | 327 | } |
| 328 | 328 | |
| 329 | 329 | if kind == "user" { |
| 330 | | all := id == c.User.ID || c.User.IsAdmin |
| 331 | | if d.Snippets, err = c.Store.CountSnippets(id, all); err != nil { |
| 330 | seeAll := id == c.User.ID || c.User.IsAdmin |
| 331 | if d.Snippets, err = c.Store.CountSnippets(id, seeAll); err != nil { |
| 332 | 332 | return c.fail(protocol.ExitFailure, "%v", err) |
| 333 | 333 | } |
| 334 | 334 | } |
internal/control/snippet.go
+9
| @@ -151,6 +151,15 @@ func runSnippetCreate(c *Ctx, args []string) int { |
| 151 | 151 | if !validSnippetVisibility(visibility) { |
| 152 | 152 | return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private") |
| 153 | 153 | } |
| 154 | if limit := c.Cfg.Limits.MaxSnippetsPerUser; limit > 0 { |
| 155 | n, err := c.Store.CountSnippets(c.User.ID, true) |
| 156 | if err != nil { |
| 157 | return c.fail(protocol.ExitFailure, "%v", err) |
| 158 | } |
| 159 | if n >= limit { |
| 160 | return c.fail(protocol.ExitUsage, "snippet limit reached (%d); delete one first", limit) |
| 161 | } |
| 162 | } |
| 154 | 163 | data, code := readSnippetBody(c) |
| 155 | 164 | if code >= 0 { |
| 156 | 165 | return code |
internal/control/snippet_test.go
added
+63
| @@ -0,0 +1,63 @@ |
| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "gitbay.org/gitbay/internal/config" |
| 11 | "gitbay.org/gitbay/internal/protocol" |
| 12 | "gitbay.org/gitbay/internal/store" |
| 13 | ) |
| 14 | |
| 15 | // A snippet already at maxSnippetFiles refuses a new name but still |
| 16 | // accepts a replacement of one it already holds. |
| 17 | func TestSnippetFileSetRefusesThe65thFile(t *testing.T) { |
| 18 | st, err := store.Open(":memory:") |
| 19 | if err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | t.Cleanup(func() { st.Close() }) |
| 23 | if err := st.MigrateUp(); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | uid, err := st.CreateUser("alice", false) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | snID, err := st.CreateSnippet(uid, "abc123abc123", "", "unlisted", "f0", []byte("x\n")) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | for i := 1; i < maxSnippetFiles; i++ { |
| 35 | if err := st.SetSnippetFile(snID, fmt.Sprintf("f%d", i), []byte("x\n")); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | ctx := func() (*Ctx, *bytes.Buffer) { |
| 41 | var out bytes.Buffer |
| 42 | return &Ctx{ |
| 43 | User: store.User{ID: uid, Username: "alice"}, |
| 44 | Scope: "full", |
| 45 | Store: st, |
| 46 | Cfg: config.Config{Limits: config.Limits{MaxSnippetBytes: 1 << 20}}, |
| 47 | Stdin: strings.NewReader("x\n"), |
| 48 | Stdout: &out, |
| 49 | Stderr: &out, |
| 50 | }, &out |
| 51 | } |
| 52 | |
| 53 | c, out := ctx() |
| 54 | if code := runSnippetFileSet(c, []string{"abc123abc123", "new.txt"}); code != protocol.ExitUsage || |
| 55 | !strings.Contains(out.String(), strconv.Itoa(maxSnippetFiles)) { |
| 56 | t.Fatalf("new file at the cap: exit %d, want %d naming %d: %s", code, protocol.ExitUsage, maxSnippetFiles, out.String()) |
| 57 | } |
| 58 | |
| 59 | c, out = ctx() |
| 60 | if code := runSnippetFileSet(c, []string{"abc123abc123", "f0"}); code != protocol.ExitOK { |
| 61 | t.Fatalf("replacing an existing file at the cap: exit %d: %s", code, out.String()) |
| 62 | } |
| 63 | } |
internal/store/snippets.go
+3 −1
| @@ -154,7 +154,9 @@ func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) |
| 154 | 154 | if err := rows.Err(); err != nil { |
| 155 | 155 | return nil, err |
| 156 | 156 | } |
| 157 | | // One query per row for the names; pages are at most 200 rows. |
| 157 | // One query per row for the names. Command callers page at 200 rows |
| 158 | // or fewer; the web list page is uncapped, which the per-account |
| 159 | // snippet limit bounds. |
| 158 | 160 | for i := range out { |
| 159 | 161 | if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil { |
| 160 | 162 | return nil, err |