internal/control/release.go

93b38a063b3b4b161a1baf976e92f53d02b1a221
gitbay/internal/control/release.go history · blame · raw

402 lines · 12640 bytes

  1package control
  2
  3import (
  4	"crypto/sha256"
  5	"encoding/hex"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"os"
 10	"path/filepath"
 11	"regexp"
 12	"strconv"
 13
 14	"gitbay.org/gitbay/internal/gitutil"
 15	"gitbay.org/gitbay/internal/policy"
 16	"gitbay.org/gitbay/internal/protocol"
 17	"gitbay.org/gitbay/internal/store"
 18)
 19
 20func init() {
 21	register(Command{Path: []string{"release", "create"},
 22		Summary:    "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
 23		ReadsStdin: true, Run: runReleaseCreate})
 24	register(Command{Path: []string{"release", "edit"},
 25		Summary:    "update a release's title and notes: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
 26		ReadsStdin: true, Run: runReleaseEdit})
 27	register(Command{Path: []string{"release", "list"},
 28		Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList})
 29	register(Command{Path: []string{"release", "show"},
 30		Summary: "show a release with assets: release show <owner/name> <tag>", ReadOnly: true, Run: runReleaseShow})
 31	register(Command{Path: []string{"release", "delete"},
 32		Summary: "delete a release and its assets: release delete <owner/name> <tag> --yes", Run: runReleaseDelete})
 33	register(Command{Path: []string{"release", "asset", "add"},
 34		Summary:    "upload an asset from stdin: release asset add <owner/name> <tag> <filename> < file",
 35		ReadsStdin: true, Run: runAssetAdd})
 36	register(Command{Path: []string{"release", "asset", "get"},
 37		Summary:  "write an asset to stdout: release asset get <owner/name> <tag> <filename> > file",
 38		ReadOnly: true, Run: runAssetGet})
 39	register(Command{Path: []string{"release", "asset", "remove"},
 40		Summary: "remove an asset: release asset remove <owner/name> <tag> <filename>", Run: runAssetRemove})
 41}
 42
 43var assetNamePat = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$`)
 44
 45// assetDir holds a release's uploaded files inside the bare repo directory,
 46// so backup, transfer, and delete all carry them automatically.
 47func assetDir(root string, repo store.Repo, releaseID int64) string {
 48	return filepath.Join(RepoDir(root, repo.OwnerName, repo.Name), "gitbay-releases", strconv.FormatInt(releaseID, 10))
 49}
 50
 51// releaseRef loads a release for "<owner/name> <tag>" with the permission.
 52func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Release, int) {
 53	if len(args) < 2 {
 54		return store.Repo{}, store.Release{}, c.fail(protocol.ExitUsage, "expected <owner/name> <tag>")
 55	}
 56	repo, code := resolveRepo(c, args[0], perm)
 57	if code >= 0 {
 58		return repo, store.Release{}, code
 59	}
 60	rel, err := c.Store.ReleaseByTag(repo.ID, args[1])
 61	if errors.Is(err, store.ErrNotFound) {
 62		return repo, rel, c.fail(protocol.ExitNotFound, "no release for tag %q in %s", args[1], repo.Path())
 63	}
 64	if err != nil {
 65		return repo, rel, c.fail(protocol.ExitFailure, "%v", err)
 66	}
 67	return repo, rel, -1
 68}
 69
 70func runReleaseCreate(c *Ctx, args []string) int {
 71	const usage = "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]"
 72	var path, tag, title, notes, file, format string
 73	for i := 0; i < len(args); i++ {
 74		switch args[i] {
 75		case "--title", "--notes", "--file", "--format":
 76			if i+1 >= len(args) {
 77				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 78			}
 79			switch args[i] {
 80			case "--title":
 81				title = args[i+1]
 82			case "--notes":
 83				notes = args[i+1]
 84			case "--file":
 85				file = args[i+1]
 86			case "--format":
 87				format = args[i+1]
 88			}
 89			i++
 90		default:
 91			if path == "" {
 92				path = args[i]
 93			} else if tag == "" {
 94				tag = args[i]
 95			} else {
 96				return c.fail(protocol.ExitUsage, usage)
 97			}
 98		}
 99	}
100	if path == "" || tag == "" {
101		return c.fail(protocol.ExitUsage, usage)
102	}
103	fmtName, err := markupFormat(format)
104	if err != nil {
105		return c.fail(protocol.ExitUsage, "%v", err)
106	}
107	if fmtName == "" {
108		fmtName = "md"
109	}
110	repo, code := resolveRepo(c, path, policy.CanWrite)
111	if code >= 0 {
112		return code
113	}
114	if code := refuseArchived(c, repo); code >= 0 {
115		return code
116	}
117	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
118	if _, err := gitutil.ResolveRef(dir, "refs/tags/"+tag); err != nil {
119		return c.fail(protocol.ExitNotFound, "no tag %q in %s — push the tag first", tag, repo.Path())
120	}
121	body, err := bodyFrom(c, notes, file)
122	if err != nil {
123		return c.fail(protocol.ExitUsage, "%v", err)
124	}
125	if title == "" {
126		title = tag
127	}
128	if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID, fmtName); err != nil {
129		return c.fail(protocol.ExitUsage, "%v", err)
130	}
131	c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
132	return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
133		fmt.Fprintf(w, "created release %s on %s\n", tag, repo.Path())
134	})
135}
136
137type assetOut struct {
138	Name   string `json:"name"`
139	Size   int64  `json:"size"`
140	SHA256 string `json:"sha256"`
141}
142
143type releaseOut struct {
144	Tag         string     `json:"tag"`
145	Title       string     `json:"title"`
146	Notes       string     `json:"notes,omitempty"`
147	NotesFormat string     `json:"notes_format,omitempty"`
148	Author      string     `json:"author,omitempty"`
149	CreatedAt   string     `json:"created_at"`
150	Assets      []assetOut `json:"assets,omitempty"`
151}
152
153func releaseToOut(r store.Release, withNotes bool) releaseOut {
154	o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
155	if withNotes {
156		o.Notes = r.Notes
157		o.NotesFormat = r.NotesFormat
158	}
159	for _, a := range r.Assets {
160		o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
161	}
162	return o
163}
164
165func runReleaseEdit(c *Ctx, args []string) int {
166	const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]"
167	var path, tag, title, notes, file, format string
168	var setTitle, setNotes bool
169	for i := 0; i < len(args); i++ {
170		switch args[i] {
171		case "--title", "--notes", "--file", "--format":
172			if i+1 >= len(args) {
173				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
174			}
175			switch args[i] {
176			case "--title":
177				title, setTitle = args[i+1], true
178			case "--notes":
179				notes, setNotes = args[i+1], true
180			case "--file":
181				file, setNotes = args[i+1], true
182			case "--format":
183				format = args[i+1]
184			}
185			i++
186		default:
187			if path == "" {
188				path = args[i]
189			} else if tag == "" {
190				tag = args[i]
191			} else {
192				return c.fail(protocol.ExitUsage, usage)
193			}
194		}
195	}
196	fmtName, err := markupFormat(format)
197	if err != nil {
198		return c.fail(protocol.ExitUsage, "%v", err)
199	}
200	if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") {
201		return c.fail(protocol.ExitUsage, usage)
202	}
203	repo, code := resolveRepo(c, path, policy.CanWrite)
204	if code >= 0 {
205		return code
206	}
207	if code := refuseArchived(c, repo); code >= 0 {
208		return code
209	}
210	rel, err := c.Store.ReleaseByTag(repo.ID, tag)
211	if err != nil {
212		return c.fail(protocol.ExitNotFound, "no release %q in %s", tag, repo.Path())
213	}
214	// Absent flags keep what the release already says.
215	if !setTitle {
216		title = rel.Title
217	} else if title == "" {
218		title = tag
219	}
220	body := rel.Notes
221	if setNotes {
222		if body, err = bodyFrom(c, notes, file); err != nil {
223			return c.fail(protocol.ExitUsage, "%v", err)
224		}
225	}
226	if fmtName == "" {
227		fmtName = rel.NotesFormat
228	}
229	if err := c.Store.UpdateRelease(repo.ID, tag, title, body, fmtName); err != nil {
230		return c.fail(protocol.ExitFailure, "%v", err)
231	}
232	return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
233		fmt.Fprintf(w, "updated release %s\n", tag)
234	})
235}
236
237func runReleaseList(c *Ctx, args []string) int {
238	if len(args) != 1 {
239		return c.fail(protocol.ExitUsage, "usage: release list <owner/name>")
240	}
241	repo, code := resolveRepo(c, args[0], policy.CanRead)
242	if code >= 0 {
243		return code
244	}
245	rels, err := c.Store.ListReleases(repo.ID)
246	if err != nil {
247		return c.fail(protocol.ExitFailure, "%v", err)
248	}
249	var ds []releaseOut
250	for _, r := range rels {
251		ds = append(ds, releaseToOut(r, false))
252	}
253	return c.emit(ds, func(w io.Writer) {
254		for _, d := range ds {
255			fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, d.Title, len(d.Assets))
256		}
257	})
258}
259
260func runReleaseShow(c *Ctx, args []string) int {
261	_, rel, code := releaseRef(c, args, policy.CanRead)
262	if code >= 0 {
263		return code
264	}
265	d := releaseToOut(rel, true)
266	return c.emit(d, func(w io.Writer) {
267		fmt.Fprintf(w, "%s\t%s\tby %s on %s\n", d.Tag, d.Title, d.Author, d.CreatedAt)
268		if d.Notes != "" {
269			fmt.Fprintf(w, "\n%s\n", d.Notes)
270		}
271		for _, a := range d.Assets {
272			fmt.Fprintf(w, "%s\t%d\t%s\n", a.Name, a.Size, a.SHA256)
273		}
274	})
275}
276
277func runReleaseDelete(c *Ctx, args []string) int {
278	var rest []string
279	var yes bool
280	for _, a := range args {
281		if a == "--yes" {
282			yes = true
283		} else {
284			rest = append(rest, a)
285		}
286	}
287	repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
288	if code >= 0 {
289		return code
290	}
291	if !yes {
292		return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
293	}
294	if err := c.Store.DeleteRelease(rel.ID); err != nil {
295		return c.fail(protocol.ExitFailure, "%v", err)
296	}
297	os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
298	return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
299		fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
300	})
301}
302
303func runAssetAdd(c *Ctx, args []string) int {
304	if len(args) != 3 {
305		return c.fail(protocol.ExitUsage, "usage: release asset add <owner/name> <tag> <filename> < file")
306	}
307	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
308	if code >= 0 {
309		return code
310	}
311	if code := refuseArchived(c, repo); code >= 0 {
312		return code
313	}
314	name := args[2]
315	if !assetNamePat.MatchString(name) {
316		return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
317	}
318	dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
319	if err := os.MkdirAll(dir, 0o750); err != nil {
320		return c.fail(protocol.ExitFailure, "%v", err)
321	}
322	tmp, err := os.CreateTemp(dir, ".upload-*")
323	if err != nil {
324		return c.fail(protocol.ExitFailure, "%v", err)
325	}
326	defer os.Remove(tmp.Name())
327	h := sha256.New()
328	limit := c.Cfg.Limits.MaxAssetBytes
329	n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
330	if err != nil {
331		return c.fail(protocol.ExitFailure, "reading asset: %v", err)
332	}
333	if n > limit {
334		return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
335	}
336	if n == 0 {
337		return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
338	}
339	if err := tmp.Close(); err != nil {
340		return c.fail(protocol.ExitFailure, "%v", err)
341	}
342	sum := hex.EncodeToString(h.Sum(nil))
343	if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
344		return c.fail(protocol.ExitUsage, "%v", err)
345	}
346	if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
347		c.Store.RemoveReleaseAsset(rel.ID, name)
348		return c.fail(protocol.ExitFailure, "%v", err)
349	}
350	return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
351		fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
352	})
353}
354
355func runAssetGet(c *Ctx, args []string) int {
356	if len(args) != 3 {
357		return c.fail(protocol.ExitUsage, "usage: release asset get <owner/name> <tag> <filename> > file")
358	}
359	repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
360	if code >= 0 {
361		return code
362	}
363	name := args[2]
364	if !assetNamePat.MatchString(name) {
365		return c.fail(protocol.ExitNotFound, "no asset %q", name)
366	}
367	f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
368	if err != nil {
369		return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
370	}
371	defer f.Close()
372	if _, err := io.Copy(c.Stdout, f); err != nil {
373		return protocol.ExitFailure
374	}
375	return protocol.ExitOK
376}
377
378func runAssetRemove(c *Ctx, args []string) int {
379	if len(args) != 3 {
380		return c.fail(protocol.ExitUsage, "usage: release asset remove <owner/name> <tag> <filename>")
381	}
382	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
383	if code >= 0 {
384		return code
385	}
386	if code := refuseArchived(c, repo); code >= 0 {
387		return code
388	}
389	name := args[2]
390	if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
391		if errors.Is(err, store.ErrNotFound) {
392			return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
393		}
394		return c.fail(protocol.ExitFailure, "%v", err)
395	}
396	if assetNamePat.MatchString(name) {
397		os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
398	}
399	return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
400		fmt.Fprintf(w, "removed %s\n", name)
401	})
402}