internal/control/snippet.go

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

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}