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