internal/control/snippet.go

445 lines · 14265 bytes

  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}