Commit 51479b2659

51479b2659772b27f812ddc666029094237ce3c4

parent: 18224b8fd1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 00:40 UTC

control: snippet commands

create, show, list, edit, delete, and file set|get|remove. Content is
UTF-8 under limits.max_snippet_bytes per file, 64 files per snippet.
Private snippets are not-found to everyone but the owner and admins.

Ref #195
cmd/gitbay/main.go +16
@@ -76,6 +76,22 @@ func newRoot() *cobra.Command {
7676 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
7777 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}),
7878 ),
79 group("snippet", "shared text files, outside any repository",
80 pass("create", "create from one file on stdin: <filename> [--description d] [--visibility public|unlisted|private] < file",
81 passOpts{server: []string{"snippet", "create"}, alwaysStdin: true, stdinWhat: "the file's text"}),
82 pass("show", "metadata and files: <id>", passOpts{server: []string{"snippet", "show"}}),
83 pass("list", "your snippets, or an owner's public ones: [<owner>] [--limit n] [--cursor c]",
84 passOpts{server: []string{"snippet", "list"}}),
85 pass("edit", "change description or visibility: <id> [--description d] [--visibility v]",
86 passOpts{server: []string{"snippet", "edit"}}),
87 pass("delete", "delete a snippet: <id>", passOpts{server: []string{"snippet", "delete"}}),
88 group("file", "the files in a snippet",
89 pass("set", "add or replace a file from stdin: <id> <filename> < file",
90 passOpts{server: []string{"snippet", "file", "set"}, alwaysStdin: true, stdinWhat: "the file's text"}),
91 pass("get", "print a file: <id> <filename> > file", passOpts{server: []string{"snippet", "file", "get"}}),
92 pass("remove", "remove a file: <id> <filename>", passOpts{server: []string{"snippet", "file", "remove"}}),
93 ),
94 ),
7995 repoCmd(),
8096 issueCmd(),
8197 milestoneCmd(),
e2e/readonly_test.go +6
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "os"
99 "path/filepath"
10 "regexp"
1011 "strings"
1112 "testing"
1213
@@ -71,6 +72,8 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
7172 must("", "status", "set", "alice/app", sha, "--context", "ci/x", "--state", "success")
7273 must("", "release", "create", "alice/app", "v1", "--title", "first")
7374 must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt")
75 snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
76 snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]
7477 must("", "org", "create", "theorg")
7578 must("", "org", "team", "create", "theorg", "core")
7679 must("", "token", "create", "--name", "t")
@@ -146,6 +149,9 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
146149 "release list": {"alice/app"},
147150 "release show": {"alice/app", "v1"},
148151 "release asset get": {"alice/app", "v1", "a.txt"},
152 "snippet show": {snippetID},
153 "snippet list": {},
154 "snippet file get": {snippetID, "a.txt"},
149155 "notifications list": nil,
150156 "notifications settings show": nil,
151157 "repo bookmarks": nil,
e2e/snippet_test.go added +137
@@ -0,0 +1,137 @@
1package e2e
2
3import (
4 "encoding/json"
5 "regexp"
6 "strings"
7 "testing"
8)
9
10// Snippets over SSH: create from stdin, read back, list by visibility,
11// edit files and metadata, and the not-found rule for private ones.
12func TestSnippets(t *testing.T) {
13 inst := startInstance(t)
14 aliceKey := inst.newKey(t, "alice")
15 bobKey := inst.newKey(t, "bob")
16 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
18 must := func(key, stdin string, args ...string) string {
19 t.Helper()
20 out, errOut, code := inst.ssh(t, key, stdin, args...)
21 if code != 0 {
22 t.Fatalf("%v: exit %d %s", args, code, errOut)
23 }
24 return out
25 }
26 fails := func(key, stdin string, want int, args ...string) string {
27 t.Helper()
28 _, errOut, code := inst.ssh(t, key, stdin, args...)
29 if code != want {
30 t.Fatalf("%v: exit %d, want %d: %s", args, code, want, errOut)
31 }
32 return errOut
33 }
34 idOf := func(out string) string {
35 t.Helper()
36 var env struct {
37 Data struct {
38 ID string `json:"id"`
39 URL string `json:"url"`
40 } `json:"data"`
41 }
42 if err := json.Unmarshal([]byte(out), &env); err != nil || !regexp.MustCompile(`^[0-9a-f]{12}$`).MatchString(env.Data.ID) {
43 t.Fatalf("create output: %s", out)
44 }
45 if !strings.HasSuffix(env.Data.URL, "/alice/-/snippets/"+env.Data.ID) {
46 t.Fatalf("url: %s", env.Data.URL)
47 }
48 return env.Data.ID
49 }
50
51 // Create with the default visibility, read back byte for byte.
52 body := "line one\nline two\n"
53 unlisted := idOf(must(aliceKey, body, "snippet", "create", "build.log", "--description", "'a log'", "--json"))
54 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != body {
55 t.Fatalf("file get: %q", got)
56 }
57 out := must(aliceKey, "", "snippet", "show", unlisted, "--json")
58 if !strings.Contains(out, `"visibility":"unlisted"`) || !strings.Contains(out, `"content":"line one\nline two\n"`) {
59 t.Fatalf("show: %s", out)
60 }
61 public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json"))
62 private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json"))
63
64 // Refusals on create: empty, not text, over the limit, bad name.
65 fails(aliceKey, "", 2, "snippet", "create", "x.txt")
66 fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin")
67 fails(aliceKey, strings.Repeat("x", 1<<20+1), 2, "snippet", "create", "big.txt")
68 fails(aliceKey, "x\n", 2, "snippet", "create", "../x")
69 fails(aliceKey, "x\n", 2, "snippet", "create", "x.txt", "--visibility", "secret")
70
71 // Visibility from the other side. Private is not-found, never denied.
72 fails(bobKey, "", 3, "snippet", "show", private)
73 fails(bobKey, "", 3, "snippet", "file", "get", private, "b.txt")
74 must(bobKey, "", "snippet", "show", unlisted)
75 out = must(bobKey, "", "snippet", "list", "alice", "--json")
76 if !strings.Contains(out, public) || strings.Contains(out, unlisted) || strings.Contains(out, private) {
77 t.Fatalf("bob's view of alice's list: %s", out)
78 }
79 out = must(aliceKey, "", "snippet", "list", "--json")
80 for _, id := range []string{public, unlisted, private} {
81 if !strings.Contains(out, id) {
82 t.Fatalf("alice's own list lacks %s: %s", id, out)
83 }
84 }
85 fails(bobKey, "", 3, "snippet", "list", "nobody")
86
87 // Paging: two pages of one, the second reached by cursor.
88 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--json")
89 var page struct {
90 Data struct {
91 Items []struct {
92 ID string `json:"id"`
93 } `json:"items"`
94 Next string `json:"next"`
95 } `json:"data"`
96 }
97 json.Unmarshal([]byte(out), &page)
98 if len(page.Data.Items) != 1 || page.Data.Items[0].ID != private || page.Data.Next == "" {
99 t.Fatalf("first page: %s", out)
100 }
101 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--cursor", page.Data.Next, "--json")
102 if !strings.Contains(out, public) {
103 t.Fatalf("second page: %s", out)
104 }
105
106 // Files: set adds, set replaces, remove drops, the last one stays.
107 must(aliceKey, "notes\n", "snippet", "file", "set", unlisted, "notes.txt")
108 must(aliceKey, "changed\n", "snippet", "file", "set", unlisted, "build.log")
109 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != "changed\n" {
110 t.Fatalf("after replace: %q", got)
111 }
112 must(aliceKey, "", "snippet", "file", "remove", unlisted, "notes.txt")
113 fails(aliceKey, "", 3, "snippet", "file", "remove", unlisted, "notes.txt")
114 if msg := fails(aliceKey, "", 2, "snippet", "file", "remove", unlisted, "build.log"); !strings.Contains(msg, "at least one file") {
115 t.Fatalf("last file removal: %s", msg)
116 }
117
118 // Only the owner writes: denied on a readable one, not-found on a private one.
119 fails(bobKey, "x\n", 4, "snippet", "file", "set", unlisted, "x.txt")
120 fails(bobKey, "", 4, "snippet", "edit", unlisted, "--description", "mine")
121 fails(bobKey, "", 4, "snippet", "delete", unlisted)
122 fails(bobKey, "", 3, "snippet", "delete", private)
123
124 // Edit moves visibility and the listing follows.
125 fails(aliceKey, "", 2, "snippet", "edit", unlisted)
126 must(aliceKey, "", "snippet", "edit", unlisted, "--visibility", "public", "--description", "shared")
127 out = must(bobKey, "", "snippet", "list", "alice", "--json")
128 if !strings.Contains(out, unlisted) || !strings.Contains(out, `"description":"shared"`) {
129 t.Fatalf("list after edit: %s", out)
130 }
131
132 // Delete, then gone; deleting the user takes the rest.
133 must(aliceKey, "", "snippet", "delete", unlisted)
134 fails(aliceKey, "", 3, "snippet", "show", unlisted)
135 inst.admin(t, "admin", "user", "delete", "alice", "--yes")
136 fails(bobKey, "", 3, "snippet", "show", public)
137}
internal/config/config.go +3 −1
@@ -175,7 +175,8 @@ type Deps struct {
175175type Limits struct {
176176 MaxPackBytes int64 `toml:"max_pack_bytes"`
177177 MaxBlobBytes int64 `toml:"max_blob_bytes"`
178 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
178 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
179 MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
179180 CloneTimeoutSec int `toml:"clone_timeout"`
180181 SSHAuthRate int `toml:"ssh_auth_rate"`
181182 // APIRate is sustained JSON-API requests per minute per caller; writes
@@ -216,6 +217,7 @@ func Default() Config {
216217 MaxPackBytes: 2 << 30, // 2 GiB
217218 MaxBlobBytes: 100 << 20,
218219 MaxAssetBytes: 512 << 20,
220 MaxSnippetBytes: 1 << 20,
219221 CloneTimeoutSec: 3600,
220222 SSHAuthRate: 10,
221223 APIRate: 120,
internal/control/snippet.go added +373
@@ -0,0 +1,373 @@
1package control
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "strconv"
10 "unicode/utf8"
11
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17// A snippet keeps at most this many files; a paste is not a repository.
18const maxSnippetFiles = 64
19
20func init() {
21 register(Command{Path: []string{"snippet", "create"},
22 Summary: "create a snippet from one file on stdin",
23 Usage: "snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file",
24 ReadsStdin: true, Run: runSnippetCreate})
25 register(Command{Path: []string{"snippet", "show"},
26 Summary: "show a snippet's metadata and files",
27 Usage: "snippet show <id>", ReadOnly: true, Run: runSnippetShow})
28 register(Command{Path: []string{"snippet", "list"},
29 Summary: "list your snippets, or an owner's public ones",
30 Usage: "snippet list [<owner>] [--limit n] [--cursor c]", ReadOnly: true, Run: runSnippetList})
31 register(Command{Path: []string{"snippet", "edit"},
32 Summary: "change a snippet's description or visibility",
33 Usage: "snippet edit <id> [--description <d>] [--visibility public|unlisted|private]", Run: runSnippetEdit})
34 register(Command{Path: []string{"snippet", "delete"},
35 Summary: "delete a snippet and its files",
36 Usage: "snippet delete <id>", Run: runSnippetDelete})
37 register(Command{Path: []string{"snippet", "file", "set"},
38 Summary: "add a file to a snippet, or replace one, from stdin",
39 Usage: "snippet file set <id> <filename> < file",
40 ReadsStdin: true, Run: runSnippetFileSet})
41 register(Command{Path: []string{"snippet", "file", "get"},
42 Summary: "write a snippet file to stdout",
43 Usage: "snippet file get <id> <filename> > file", ReadOnly: true, Run: runSnippetFileGet})
44 register(Command{Path: []string{"snippet", "file", "remove"},
45 Summary: "remove a file from a snippet",
46 Usage: "snippet file remove <id> <filename>", Run: runSnippetFileRemove})
47}
48
49type SnippetFileOut struct {
50 Name string `json:"name"`
51 Size int64 `json:"size"`
52 Content string `json:"content,omitempty"`
53}
54
55type SnippetOut struct {
56 ID string `json:"id"`
57 URL string `json:"url"`
58 Owner string `json:"owner"`
59 Description string `json:"description"`
60 Visibility string `json:"visibility"`
61 CreatedAt string `json:"created_at"`
62 UpdatedAt string `json:"updated_at"`
63 Files []SnippetFileOut `json:"files"`
64}
65
66func snippetURL(c *Ctx, sn store.Snippet) string {
67 return c.Cfg.Server.SiteURL + "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
68}
69
70func snippetOut(c *Ctx, sn store.Snippet) SnippetOut {
71 o := SnippetOut{ID: sn.PublicID, URL: snippetURL(c, sn), Owner: sn.OwnerName,
72 Description: sn.Description, Visibility: sn.Visibility,
73 CreatedAt: sn.CreatedAt, UpdatedAt: sn.UpdatedAt, Files: []SnippetFileOut{}}
74 for _, f := range sn.Files {
75 o.Files = append(o.Files, SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)})
76 }
77 return o
78}
79
80func validSnippetVisibility(v string) bool {
81 return v == "public" || v == "unlisted" || v == "private"
82}
83
84// snippetRef loads a snippet the caller may read; with write, one they
85// may change. Unreadable and missing are the same not-found, so a
86// private id cannot be confirmed by probing.
87func snippetRef(c *Ctx, id string, write bool) (store.Snippet, int) {
88 sn, err := c.Store.SnippetByPublicID(id)
89 if err != nil && !errors.Is(err, store.ErrNotFound) {
90 return sn, c.fail(protocol.ExitFailure, "%v", err)
91 }
92 if err != nil || !policy.CanReadSnippet(c.User, sn) {
93 return sn, c.fail(protocol.ExitNotFound, "no snippet %q", id)
94 }
95 if write && !policy.CanWriteSnippet(c.User, sn) {
96 return sn, c.fail(protocol.ExitDenied, "snippet %s belongs to %s", id, sn.OwnerName)
97 }
98 return sn, -1
99}
100
101// readSnippetBody reads one file from stdin under the limit, and insists
102// on text: the page highlights it and the raw route serves text/plain.
103func readSnippetBody(c *Ctx) ([]byte, int) {
104 limit := c.Cfg.Limits.MaxSnippetBytes
105 data, err := io.ReadAll(io.LimitReader(c.Stdin, limit+1))
106 if err != nil {
107 return nil, c.fail(protocol.ExitFailure, "reading stdin: %v", err)
108 }
109 if int64(len(data)) > limit {
110 return nil, c.fail(protocol.ExitUsage, "file exceeds max_snippet_bytes (%d)", limit)
111 }
112 if len(data) == 0 {
113 return nil, c.fail(protocol.ExitUsage, "empty file: pipe it on stdin")
114 }
115 if !utf8.Valid(data) {
116 return nil, c.fail(protocol.ExitUsage, "snippets hold text: the file is not valid UTF-8")
117 }
118 return data, -1
119}
120
121func checkSnippetFileName(c *Ctx, name string) int {
122 if !assetNamePat.MatchString(name) {
123 return c.fail(protocol.ExitUsage, "invalid file name %q: letters, digits, '._+-'; must not start with '.'", name)
124 }
125 return -1
126}
127
128func newSnippetID() string {
129 buf := make([]byte, 6)
130 rand.Read(buf)
131 return hex.EncodeToString(buf)
132}
133
134func runSnippetCreate(c *Ctx, args []string) int {
135 const usage = "usage: snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file"
136 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
137 if err != nil {
138 return c.fail(protocol.ExitUsage, "%v", err)
139 }
140 name := f.pos(0)
141 if name == "" {
142 return c.fail(protocol.ExitUsage, usage)
143 }
144 if code := checkSnippetFileName(c, name); code >= 0 {
145 return code
146 }
147 visibility := f.Value("--visibility")
148 if visibility == "" {
149 visibility = "unlisted"
150 }
151 if !validSnippetVisibility(visibility) {
152 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
153 }
154 data, code := readSnippetBody(c)
155 if code >= 0 {
156 return code
157 }
158 var pid string
159 for try := 0; ; try++ {
160 pid = newSnippetID()
161 _, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
162 if !errors.Is(err, store.ErrExists) || try == 4 {
163 break
164 }
165 }
166 if err != nil {
167 return c.failErr(err)
168 }
169 sn, err := c.Store.SnippetByPublicID(pid)
170 if err != nil {
171 return c.fail(protocol.ExitFailure, "%v", err)
172 }
173 return c.emit(snippetOut(c, sn), func(w io.Writer) {
174 fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
175 })
176}
177
178func runSnippetShow(c *Ctx, args []string) int {
179 if len(args) != 1 {
180 return c.fail(protocol.ExitUsage, "usage: snippet show <id>")
181 }
182 sn, code := snippetRef(c, args[0], false)
183 if code >= 0 {
184 return code
185 }
186 files, err := c.Store.SnippetFiles(sn.ID)
187 if err != nil {
188 return c.fail(protocol.ExitFailure, "%v", err)
189 }
190 sn.Files = files
191 return c.emit(snippetOut(c, sn), func(w io.Writer) {
192 fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
193 if sn.Description != "" {
194 fmt.Fprintf(w, "%s\n", sn.Description)
195 }
196 fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
197 for _, f := range files {
198 fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size)
199 }
200 })
201}
202
203func runSnippetList(c *Ctx, args []string) int {
204 rest, p, code := parsePageFlags(c, args, "snippet", true)
205 if code >= 0 {
206 return code
207 }
208 if len(rest) > 1 {
209 return c.fail(protocol.ExitUsage, "usage: snippet list [<owner>] [--limit n] [--cursor c]")
210 }
211 owner := c.User
212 if len(rest) == 1 {
213 u, err := c.Store.UserByUsername(rest[0])
214 if errors.Is(err, store.ErrNotFound) {
215 return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
216 }
217 if err != nil {
218 return c.fail(protocol.ExitFailure, "%v", err)
219 }
220 owner = u
221 }
222 all := owner.ID == c.User.ID || c.User.IsAdmin
223 rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
224 if err != nil {
225 return c.fail(protocol.ExitFailure, "%v", err)
226 }
227 rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
228 items := make([]SnippetOut, 0, len(rows))
229 for _, sn := range rows {
230 items = append(items, snippetOut(c, sn))
231 }
232 return c.emitPage(p, items, next, func(w io.Writer) {
233 for _, sn := range rows {
234 names := ""
235 for i, f := range sn.Files {
236 if i > 0 {
237 names += ", "
238 }
239 names += f.Name
240 }
241 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
242 }
243 })
244}
245
246func runSnippetEdit(c *Ctx, args []string) int {
247 const usage = "usage: snippet edit <id> [--description <d>] [--visibility public|unlisted|private]"
248 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
249 if err != nil {
250 return c.fail(protocol.ExitUsage, "%v", err)
251 }
252 if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
253 return c.fail(protocol.ExitUsage, usage)
254 }
255 sn, code := snippetRef(c, f.pos(0), true)
256 if code >= 0 {
257 return code
258 }
259 description, visibility := sn.Description, sn.Visibility
260 if f.Has("--description") {
261 description = f.Value("--description")
262 }
263 if f.Has("--visibility") {
264 visibility = f.Value("--visibility")
265 if !validSnippetVisibility(visibility) {
266 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
267 }
268 }
269 if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
270 return c.failErr(err)
271 }
272 sn, err = c.Store.SnippetByPublicID(sn.PublicID)
273 if err != nil {
274 return c.fail(protocol.ExitFailure, "%v", err)
275 }
276 return c.emit(snippetOut(c, sn), func(w io.Writer) {
277 fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
278 })
279}
280
281func runSnippetDelete(c *Ctx, args []string) int {
282 if len(args) != 1 {
283 return c.fail(protocol.ExitUsage, "usage: snippet delete <id>")
284 }
285 sn, code := snippetRef(c, args[0], true)
286 if code >= 0 {
287 return code
288 }
289 if err := c.Store.DeleteSnippet(sn.ID); err != nil {
290 return c.failErr(err)
291 }
292 return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
293 fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
294 })
295}
296
297func runSnippetFileSet(c *Ctx, args []string) int {
298 if len(args) != 2 {
299 return c.fail(protocol.ExitUsage, "usage: snippet file set <id> <filename> < file")
300 }
301 sn, code := snippetRef(c, args[0], true)
302 if code >= 0 {
303 return code
304 }
305 name := args[1]
306 if code := checkSnippetFileName(c, name); code >= 0 {
307 return code
308 }
309 exists := false
310 for _, f := range sn.Files {
311 exists = exists || f.Name == name
312 }
313 if !exists && len(sn.Files) >= maxSnippetFiles {
314 return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
315 }
316 data, code := readSnippetBody(c)
317 if code >= 0 {
318 return code
319 }
320 if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
321 return c.failErr(err)
322 }
323 return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
324 fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
325 })
326}
327
328func runSnippetFileGet(c *Ctx, args []string) int {
329 if len(args) != 2 {
330 return c.fail(protocol.ExitUsage, "usage: snippet file get <id> <filename> > file")
331 }
332 sn, code := snippetRef(c, args[0], false)
333 if code >= 0 {
334 return code
335 }
336 f, err := c.Store.SnippetFile(sn.ID, args[1])
337 if errors.Is(err, store.ErrNotFound) {
338 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
339 }
340 if err != nil {
341 return c.fail(protocol.ExitFailure, "%v", err)
342 }
343 if c.JSON {
344 return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
345 }
346 if _, err := c.Stdout.Write(f.Content); err != nil {
347 return protocol.ExitFailure
348 }
349 return protocol.ExitOK
350}
351
352func runSnippetFileRemove(c *Ctx, args []string) int {
353 if len(args) != 2 {
354 return c.fail(protocol.ExitUsage, "usage: snippet file remove <id> <filename>")
355 }
356 sn, code := snippetRef(c, args[0], true)
357 if code >= 0 {
358 return code
359 }
360 if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
361 return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
362 }
363 err := c.Store.RemoveSnippetFile(sn.ID, args[1])
364 if errors.Is(err, store.ErrNotFound) {
365 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
366 }
367 if err != nil {
368 return c.failErr(err)
369 }
370 return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
371 fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
372 })
373}
internal/policy/snippets.go added +17
@@ -0,0 +1,17 @@
1package policy
2
3import "gitbay.org/gitbay/internal/store"
4
5// CanReadSnippet: anyone for public and unlisted, the owner and admins
6// for private. Anonymous readers have user.ID 0.
7func CanReadSnippet(user store.User, sn store.Snippet) bool {
8 if sn.Visibility != "private" {
9 return true
10 }
11 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
12}
13
14// CanWriteSnippet: the owner and admins.
15func CanWriteSnippet(user store.User, sn store.Snippet) bool {
16 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
17}