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.
115115- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
116116- =max_asset_bytes= (512MB) — cap per uploaded release asset.
117117- =max_snippet_bytes= (1MB) — cap per snippet file.
118- =max_snippets_per_user= (0, unlimited) — snippets an account may own.
118119- =max_repos_per_user= (0, unlimited) — repositories an account may own
119120 directly; =repo create=, =fork= and =import= refuse past it.
120121 Organizations are not capped.
CHANGELOG.org +4 −2
@@ -13,8 +13,10 @@ shared by URL and edited in place.
1313- =snippet create|show|list|edit|delete= and =snippet file
1414 set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
1515 (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.
1820- Web: =/<owner>/-/snippets= lists, each snippet page renders its
1921 files with a raw route per file, and the owner creates, edits and
2022 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:
9494 1 MiB. Enforced on create and `file set` with the same
9595 `io.LimitReader(n+1)` shape as `release asset add`.
9696- 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.
97100- Snippet bytes do not count toward `max_bytes_per_user`; that quota
98101 measures repositories and LFS.
99102
e2e/snippet_test.go +6 −1
@@ -10,7 +10,7 @@ import (
1010// Snippets over SSH: create from stdin, read back, list by visibility,
1111// edit files and metadata, and the not-found rule for private ones.
1212func TestSnippets(t *testing.T) {
13 inst := startInstance(t)
13 inst := startInstanceWith(t, "[limits]\nmax_snippets_per_user = 3\n")
1414 aliceKey := inst.newKey(t, "alice")
1515 bobKey := inst.newKey(t, "bob")
1616 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
@@ -61,6 +61,11 @@ func TestSnippets(t *testing.T) {
6161 public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json"))
6262 private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json"))
6363
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
6469 // Refusals on create: empty, not text, over the limit, bad name.
6570 fails(aliceKey, "", 2, "snippet", "create", "x.txt")
6671 fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin")
internal/config/config.go +3
@@ -177,6 +177,9 @@ type Limits struct {
177177 MaxBlobBytes int64 `toml:"max_blob_bytes"`
178178 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
179179 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"`
180183 CloneTimeoutSec int `toml:"clone_timeout"`
181184 SSHAuthRate int `toml:"ssh_auth_rate"`
182185 // 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 {
327327 }
328328
329329 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 {
332332 return c.fail(protocol.ExitFailure, "%v", err)
333333 }
334334 }
internal/control/snippet.go +9
@@ -151,6 +151,15 @@ func runSnippetCreate(c *Ctx, args []string) int {
151151 if !validSnippetVisibility(visibility) {
152152 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
153153 }
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 }
154163 data, code := readSnippetBody(c)
155164 if code >= 0 {
156165 return code
internal/control/snippet_test.go added +63
@@ -0,0 +1,63 @@
1package control
2
3import (
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.
17func 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)
154154 if err := rows.Err(); err != nil {
155155 return nil, err
156156 }
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.
158160 for i := range out {
159161 if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
160162 return nil, err