internal/control/snippet.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/control/snippet.go history · blame · raw

415 lines · 13068 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 := c.parseArgs(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			v.section("files")
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 := c.parseArgs(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}