internal/control/snippet.go
382 lines · 12565 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 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 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 }
163 data, code := readSnippetBody(c)
164 if code >= 0 {
165 return code
166 }
167 var pid string
168 for try := 0; ; try++ {
169 pid = newSnippetID()
170 _, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
171 if !errors.Is(err, store.ErrExists) || try == 4 {
172 break
173 }
174 }
175 if err != nil {
176 return c.failErr(err)
177 }
178 sn, err := c.Store.SnippetByPublicID(pid)
179 if err != nil {
180 return c.fail(protocol.ExitFailure, "%v", err)
181 }
182 return c.emit(snippetOut(c, sn), func(w io.Writer) {
183 fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
184 })
185}
186
187func runSnippetShow(c *Ctx, args []string) int {
188 if len(args) != 1 {
189 return c.fail(protocol.ExitUsage, "usage: snippet show <id>")
190 }
191 sn, code := snippetRef(c, args[0], false)
192 if code >= 0 {
193 return code
194 }
195 files, err := c.Store.SnippetFiles(sn.ID)
196 if err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 sn.Files = files
200 return c.emit(snippetOut(c, sn), func(w io.Writer) {
201 fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
202 if sn.Description != "" {
203 fmt.Fprintf(w, "%s\n", sn.Description)
204 }
205 fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
206 for _, f := range files {
207 fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size)
208 }
209 })
210}
211
212func runSnippetList(c *Ctx, args []string) int {
213 rest, p, code := parsePageFlags(c, args, "snippet", true)
214 if code >= 0 {
215 return code
216 }
217 if len(rest) > 1 {
218 return c.fail(protocol.ExitUsage, "usage: snippet list [<owner>] [--limit n] [--cursor c]")
219 }
220 owner := c.User
221 if len(rest) == 1 {
222 u, err := c.Store.UserByUsername(rest[0])
223 if errors.Is(err, store.ErrNotFound) {
224 return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
225 }
226 if err != nil {
227 return c.fail(protocol.ExitFailure, "%v", err)
228 }
229 owner = u
230 }
231 all := owner.ID == c.User.ID || c.User.IsAdmin
232 rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
233 if err != nil {
234 return c.fail(protocol.ExitFailure, "%v", err)
235 }
236 rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
237 items := make([]SnippetOut, 0, len(rows))
238 for _, sn := range rows {
239 items = append(items, snippetOut(c, sn))
240 }
241 return c.emitPage(p, items, next, func(w io.Writer) {
242 for _, sn := range rows {
243 names := ""
244 for i, f := range sn.Files {
245 if i > 0 {
246 names += ", "
247 }
248 names += f.Name
249 }
250 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
251 }
252 })
253}
254
255func runSnippetEdit(c *Ctx, args []string) int {
256 const usage = "usage: snippet edit <id> [--description <d>] [--visibility public|unlisted|private]"
257 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
258 if err != nil {
259 return c.fail(protocol.ExitUsage, "%v", err)
260 }
261 if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
262 return c.fail(protocol.ExitUsage, usage)
263 }
264 sn, code := snippetRef(c, f.pos(0), true)
265 if code >= 0 {
266 return code
267 }
268 description, visibility := sn.Description, sn.Visibility
269 if f.Has("--description") {
270 description = f.Value("--description")
271 }
272 if f.Has("--visibility") {
273 visibility = f.Value("--visibility")
274 if !validSnippetVisibility(visibility) {
275 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
276 }
277 }
278 if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
279 return c.failErr(err)
280 }
281 sn, err = c.Store.SnippetByPublicID(sn.PublicID)
282 if err != nil {
283 return c.fail(protocol.ExitFailure, "%v", err)
284 }
285 return c.emit(snippetOut(c, sn), func(w io.Writer) {
286 fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
287 })
288}
289
290func runSnippetDelete(c *Ctx, args []string) int {
291 if len(args) != 1 {
292 return c.fail(protocol.ExitUsage, "usage: snippet delete <id>")
293 }
294 sn, code := snippetRef(c, args[0], true)
295 if code >= 0 {
296 return code
297 }
298 if err := c.Store.DeleteSnippet(sn.ID); err != nil {
299 return c.failErr(err)
300 }
301 return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
302 fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
303 })
304}
305
306func runSnippetFileSet(c *Ctx, args []string) int {
307 if len(args) != 2 {
308 return c.fail(protocol.ExitUsage, "usage: snippet file set <id> <filename> < file")
309 }
310 sn, code := snippetRef(c, args[0], true)
311 if code >= 0 {
312 return code
313 }
314 name := args[1]
315 if code := checkSnippetFileName(c, name); code >= 0 {
316 return code
317 }
318 exists := false
319 for _, f := range sn.Files {
320 exists = exists || f.Name == name
321 }
322 if !exists && len(sn.Files) >= maxSnippetFiles {
323 return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
324 }
325 data, code := readSnippetBody(c)
326 if code >= 0 {
327 return code
328 }
329 if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
330 return c.failErr(err)
331 }
332 return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
333 fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
334 })
335}
336
337func runSnippetFileGet(c *Ctx, args []string) int {
338 if len(args) != 2 {
339 return c.fail(protocol.ExitUsage, "usage: snippet file get <id> <filename> > file")
340 }
341 sn, code := snippetRef(c, args[0], false)
342 if code >= 0 {
343 return code
344 }
345 f, err := c.Store.SnippetFile(sn.ID, args[1])
346 if errors.Is(err, store.ErrNotFound) {
347 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
348 }
349 if err != nil {
350 return c.fail(protocol.ExitFailure, "%v", err)
351 }
352 if c.JSON {
353 return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
354 }
355 if _, err := c.Stdout.Write(f.Content); err != nil {
356 return protocol.ExitFailure
357 }
358 return protocol.ExitOK
359}
360
361func runSnippetFileRemove(c *Ctx, args []string) int {
362 if len(args) != 2 {
363 return c.fail(protocol.ExitUsage, "usage: snippet file remove <id> <filename>")
364 }
365 sn, code := snippetRef(c, args[0], true)
366 if code >= 0 {
367 return code
368 }
369 if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
370 return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
371 }
372 err := c.Store.RemoveSnippetFile(sn.ID, args[1])
373 if errors.Is(err, store.ErrNotFound) {
374 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
375 }
376 if err != nil {
377 return c.failErr(err)
378 }
379 return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
380 fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
381 })
382}