internal/control/snippet.go
445 lines · 14265 bytes
19 symbols in this file
1package control
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "strconv"
10 "strings"
11 "unicode/utf8"
12
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// A snippet keeps at most this many files; a paste is not a repository.
19const maxSnippetFiles = 64
20
21func init() {
22 register(Command{Path: []string{"snippet", "create"},
23 Summary: "create a snippet from one file on stdin",
24 Usage: "snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file",
25 Flags: []Flag{
26 {"--description", "<d>", "one line about the snippet", ""},
27 {"--visibility", "public|unlisted|private", "who can find it", "unlisted"},
28 },
29 Examples: []string{"snippet create notes.md --visibility private < notes.md"},
30 ReadsStdin: true, Run: runSnippetCreate})
31 register(Command{Path: []string{"snippet", "show"},
32 Summary: "show a snippet's metadata and files",
33 Usage: "snippet show <id>",
34 Examples: []string{"snippet show a1b2c3"},
35 ReadOnly: true, Run: runSnippetShow})
36 register(Command{Path: []string{"snippet", "list"},
37 Summary: "list your snippets, or an owner's public ones",
38 Usage: "snippet list [<owner>] [--limit n] [--cursor c]",
39 Flags: []Flag{
40 {"--limit", "n", "rows per page", ""},
41 {"--cursor", "c", "continue from the previous page", ""},
42 },
43 Examples: []string{"snippet list cmc"},
44 ReadOnly: true, Run: runSnippetList})
45 register(Command{Path: []string{"snippet", "edit"},
46 Summary: "change a snippet's description or visibility",
47 Usage: "snippet edit <id> [--description <d>] [--visibility public|unlisted|private]",
48 Flags: []Flag{
49 {"--description", "<d>", "one line about the snippet", ""},
50 {"--visibility", "public|unlisted|private", "who can find it", ""},
51 },
52 Examples: []string{"snippet edit a1b2c3 --visibility public"},
53 Run: runSnippetEdit})
54 register(Command{Path: []string{"snippet", "delete"},
55 Summary: "delete a snippet and its files",
56 Usage: "snippet delete <id>",
57 Examples: []string{"snippet delete a1b2c3"},
58 Run: runSnippetDelete})
59 register(Command{Path: []string{"snippet", "file", "set"},
60 Summary: "add a file to a snippet, or replace one, from stdin",
61 Usage: "snippet file set <id> <filename> < file",
62 Examples: []string{"snippet file set a1b2c3 notes.md < notes.md"},
63 ReadsStdin: true, Run: runSnippetFileSet})
64 register(Command{Path: []string{"snippet", "file", "get"},
65 Summary: "write a snippet file to stdout",
66 Usage: "snippet file get <id> <filename> > file",
67 Examples: []string{"snippet file get a1b2c3 notes.md > notes.md"},
68 ReadOnly: true, Run: runSnippetFileGet})
69 register(Command{Path: []string{"snippet", "file", "remove"},
70 Summary: "remove a file from a snippet",
71 Usage: "snippet file remove <id> <filename>",
72 Examples: []string{"snippet file remove a1b2c3 notes.md"},
73 Run: runSnippetFileRemove})
74}
75
76type SnippetFileOut struct {
77 Name string `json:"name"`
78 Size int64 `json:"size"`
79 Content string `json:"content,omitempty"`
80}
81
82type SnippetOut struct {
83 ID string `json:"id"`
84 URL string `json:"url"`
85 Owner string `json:"owner"`
86 Description string `json:"description"`
87 Visibility string `json:"visibility"`
88 CreatedAt string `json:"created_at"`
89 UpdatedAt string `json:"updated_at"`
90 Files []SnippetFileOut `json:"files"`
91}
92
93func snippetURL(c *Ctx, sn store.Snippet) string {
94 return c.Cfg.Server.SiteURL + "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
95}
96
97func snippetOut(c *Ctx, sn store.Snippet) SnippetOut {
98 o := SnippetOut{ID: sn.PublicID, URL: snippetURL(c, sn), Owner: sn.OwnerName,
99 Description: sn.Description, Visibility: sn.Visibility,
100 CreatedAt: sn.CreatedAt, UpdatedAt: sn.UpdatedAt, Files: []SnippetFileOut{}}
101 for _, f := range sn.Files {
102 o.Files = append(o.Files, SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)})
103 }
104 return o
105}
106
107func validSnippetVisibility(v string) bool {
108 return v == "public" || v == "unlisted" || v == "private"
109}
110
111// snippetRef loads a snippet the caller may read; with write, one they
112// may change. Unreadable and missing are the same not-found, so a
113// private id cannot be confirmed by probing.
114func snippetRef(c *Ctx, id string, write bool) (store.Snippet, int) {
115 sn, err := c.Store.SnippetByPublicID(id)
116 if err != nil && !errors.Is(err, store.ErrNotFound) {
117 return sn, c.fail(protocol.ExitFailure, "%v", err)
118 }
119 if err != nil || !policy.CanReadSnippet(c.User, sn) {
120 return sn, c.fail(protocol.ExitNotFound, "no snippet %q", id)
121 }
122 if write && !policy.CanWriteSnippet(c.User, sn) {
123 return sn, c.fail(protocol.ExitDenied, "snippet %s belongs to %s; only they can change it", id, sn.OwnerName)
124 }
125 return sn, -1
126}
127
128// readSnippetBody reads one file from stdin under the limit, and insists
129// on text: the page highlights it and the raw route serves text/plain.
130func readSnippetBody(c *Ctx) ([]byte, int) {
131 limit := c.Cfg.Limits.MaxSnippetBytes
132 data, err := io.ReadAll(io.LimitReader(c.Stdin, limit+1))
133 if err != nil {
134 return nil, c.fail(protocol.ExitFailure, "reading stdin: %v", err)
135 }
136 if int64(len(data)) > limit {
137 return nil, c.fail(protocol.ExitUsage, "file exceeds max_snippet_bytes (%d)", limit)
138 }
139 if len(data) == 0 {
140 return nil, c.fail(protocol.ExitUsage, "empty file: pipe it on stdin")
141 }
142 if !utf8.Valid(data) {
143 return nil, c.fail(protocol.ExitUsage, "snippets hold text: the file is not valid UTF-8")
144 }
145 return data, -1
146}
147
148func checkSnippetFileName(c *Ctx, name string) int {
149 if !assetNamePat.MatchString(name) {
150 return c.fail(protocol.ExitUsage, "invalid file name %q: letters, digits, '._+-'; must not start with '.'", name)
151 }
152 return -1
153}
154
155func newSnippetID() string {
156 buf := make([]byte, 6)
157 rand.Read(buf)
158 return hex.EncodeToString(buf)
159}
160
161func runSnippetCreate(c *Ctx, args []string) int {
162 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage})
163 if err != nil {
164 return c.fail(protocol.ExitUsage, "%v", err)
165 }
166 name := f.pos(0)
167 if name == "" {
168 return c.usage()
169 }
170 if code := checkSnippetFileName(c, name); code >= 0 {
171 return code
172 }
173 visibility := f.Value("--visibility")
174 if visibility == "" {
175 visibility = "unlisted"
176 }
177 if !validSnippetVisibility(visibility) {
178 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
179 }
180 if limit := c.Cfg.Limits.MaxSnippetsPerUser; limit > 0 {
181 n, err := c.Store.CountSnippets(c.User.ID, true)
182 if err != nil {
183 return c.fail(protocol.ExitFailure, "%v", err)
184 }
185 if n >= limit {
186 return c.fail(protocol.ExitUsage, "snippet limit reached (%d); delete one first", limit)
187 }
188 }
189 data, code := readSnippetBody(c)
190 if code >= 0 {
191 return code
192 }
193 var pid string
194 for try := 0; ; try++ {
195 pid = newSnippetID()
196 _, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
197 if !errors.Is(err, store.ErrExists) || try == 4 {
198 break
199 }
200 }
201 if err != nil {
202 return c.failErr(err)
203 }
204 sn, err := c.Store.SnippetByPublicID(pid)
205 if err != nil {
206 return c.fail(protocol.ExitFailure, "%v", err)
207 }
208 return c.emit(snippetOut(c, sn), func(w io.Writer) {
209 fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
210 })
211}
212
213func runSnippetShow(c *Ctx, args []string) int {
214 if len(args) != 1 {
215 return c.usage()
216 }
217 sn, code := snippetRef(c, args[0], false)
218 if code >= 0 {
219 return code
220 }
221 files, err := c.Store.SnippetFiles(sn.ID)
222 if err != nil {
223 return c.fail(protocol.ExitFailure, "%v", err)
224 }
225 sn.Files = files
226 return c.emitView(snippetOut(c, sn), func(w io.Writer) {
227 v := c.view(w)
228 v.title(sn.PublicID, sn.Description, sn.Visibility)
229 v.fields(
230 "author", sn.OwnerName,
231 "updated", c.when(sn.UpdatedAt),
232 "url", snippetURL(c, sn),
233 )
234 if len(files) > 0 {
235 v.section("files")
236 tb := c.table(w, "NAME", "SIZE")
237 for _, f := range files {
238 tb.row(cRef(f.Name), cText(fmt.Sprintf("%d bytes", f.Size)))
239 }
240 tb.flush()
241 }
242 }, func() screen {
243 fs := section{title: "Files", n: len(files)}
244 for _, f := range files {
245 fs.rows = append(fs.rows, rowOf(cRef(f.Name), cSize(int64(f.Size))))
246 }
247 s := screen{sections: []section{fs}, fields: []field{
248 {"Snippet", []cell{cLink(sn.PublicID, snippetURL(c, sn)), cText(sn.Description)}},
249 {"Owner", []cell{cText(sn.OwnerName), cState(sn.Visibility)}},
250 {"Updated", []cell{cAge(sn.UpdatedAt)}},
251 }}
252 if len(files) > 0 {
253 s.actions = append(s.actions, action{"Get", []string{"snippet", "file", "get", sn.PublicID, files[0].Name}})
254 }
255 s.actions = append(s.actions, action{"Edit", []string{"snippet", "edit", sn.PublicID, "--description", "<text>"}})
256 return s
257 })
258}
259
260func runSnippetList(c *Ctx, args []string) int {
261 rest, p, code := parsePageFlags(c, args, "snippet", true)
262 if code >= 0 {
263 return code
264 }
265 if len(rest) > 1 {
266 return c.usage()
267 }
268 owner := c.User
269 if len(rest) == 1 {
270 u, err := c.Store.UserByUsername(rest[0])
271 if errors.Is(err, store.ErrNotFound) {
272 return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
273 }
274 if err != nil {
275 return c.fail(protocol.ExitFailure, "%v", err)
276 }
277 owner = u
278 }
279 all := owner.ID == c.User.ID || c.User.IsAdmin
280 rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
281 if err != nil {
282 return c.fail(protocol.ExitFailure, "%v", err)
283 }
284 rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
285 items := make([]SnippetOut, 0, len(rows))
286 for _, sn := range rows {
287 items = append(items, snippetOut(c, sn))
288 }
289 return c.emitPageView(p, items, next, func(w io.Writer) {
290 tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION")
291 for _, sn := range rows {
292 names := ""
293 for i, f := range sn.Files {
294 if i > 0 {
295 names += ", "
296 }
297 names += f.Name
298 }
299 tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description))
300 }
301 tb.flush()
302 }, func() screen {
303 rs := make([]row, len(rows))
304 for i, sn := range rows {
305 names := make([]string, len(sn.Files))
306 for j, f := range sn.Files {
307 names[j] = f.Name
308 }
309 rs[i] = rowOf(cRef(sn.PublicID), cState(sn.Visibility), cFlex(sn.Description), cMeta(strings.Join(names, ", "), relAge(sn.UpdatedAt, termNow())))
310 }
311 s := listScreen("Snippets", rs)
312 if len(rows) > 0 {
313 s.actions = []action{{"Read", []string{"snippet", "show", rows[0].PublicID}}}
314 }
315 return s
316 })
317}
318
319func runSnippetEdit(c *Ctx, args []string) int {
320 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage})
321 if err != nil {
322 return c.fail(protocol.ExitUsage, "%v", err)
323 }
324 if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
325 return c.usage()
326 }
327 sn, code := snippetRef(c, f.pos(0), true)
328 if code >= 0 {
329 return code
330 }
331 description, visibility := sn.Description, sn.Visibility
332 if f.Has("--description") {
333 description = f.Value("--description")
334 }
335 if f.Has("--visibility") {
336 visibility = f.Value("--visibility")
337 if !validSnippetVisibility(visibility) {
338 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
339 }
340 }
341 if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
342 return c.failErr(err)
343 }
344 sn, err = c.Store.SnippetByPublicID(sn.PublicID)
345 if err != nil {
346 return c.fail(protocol.ExitFailure, "%v", err)
347 }
348 return c.emit(snippetOut(c, sn), func(w io.Writer) {
349 fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
350 })
351}
352
353func runSnippetDelete(c *Ctx, args []string) int {
354 if len(args) != 1 {
355 return c.usage()
356 }
357 sn, code := snippetRef(c, args[0], true)
358 if code >= 0 {
359 return code
360 }
361 if err := c.Store.DeleteSnippet(sn.ID); err != nil {
362 return c.failErr(err)
363 }
364 return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
365 fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
366 })
367}
368
369func runSnippetFileSet(c *Ctx, args []string) int {
370 if len(args) != 2 {
371 return c.usage()
372 }
373 sn, code := snippetRef(c, args[0], true)
374 if code >= 0 {
375 return code
376 }
377 name := args[1]
378 if code := checkSnippetFileName(c, name); code >= 0 {
379 return code
380 }
381 exists := false
382 for _, f := range sn.Files {
383 exists = exists || f.Name == name
384 }
385 if !exists && len(sn.Files) >= maxSnippetFiles {
386 return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
387 }
388 data, code := readSnippetBody(c)
389 if code >= 0 {
390 return code
391 }
392 if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
393 return c.failErr(err)
394 }
395 return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
396 fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
397 })
398}
399
400func runSnippetFileGet(c *Ctx, args []string) int {
401 if len(args) != 2 {
402 return c.usage()
403 }
404 sn, code := snippetRef(c, args[0], false)
405 if code >= 0 {
406 return code
407 }
408 f, err := c.Store.SnippetFile(sn.ID, args[1])
409 if errors.Is(err, store.ErrNotFound) {
410 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
411 }
412 if err != nil {
413 return c.fail(protocol.ExitFailure, "%v", err)
414 }
415 if c.JSON {
416 return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
417 }
418 if _, err := c.Stdout.Write(f.Content); err != nil {
419 return protocol.ExitFailure
420 }
421 return protocol.ExitOK
422}
423
424func runSnippetFileRemove(c *Ctx, args []string) int {
425 if len(args) != 2 {
426 return c.usage()
427 }
428 sn, code := snippetRef(c, args[0], true)
429 if code >= 0 {
430 return code
431 }
432 if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
433 return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
434 }
435 err := c.Store.RemoveSnippetFile(sn.ID, args[1])
436 if errors.Is(err, store.ErrNotFound) {
437 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
438 }
439 if err != nil {
440 return c.failErr(err)
441 }
442 return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
443 fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
444 })
445}