internal/control/release.go

7b5be134cce1a087ae1096772cbd6f76716d307f
gitbay/internal/control/release.go history · blame · raw

381 lines · 12051 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 -]",
 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 -]",
 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	var path, tag, title, notes, file string
 72	for i := 0; i < len(args); i++ {
 73		switch args[i] {
 74		case "--title", "--notes", "--file":
 75			if i+1 >= len(args) {
 76				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 77			}
 78			switch args[i] {
 79			case "--title":
 80				title = args[i+1]
 81			case "--notes":
 82				notes = args[i+1]
 83			case "--file":
 84				file = args[i+1]
 85			}
 86			i++
 87		default:
 88			if path == "" {
 89				path = args[i]
 90			} else if tag == "" {
 91				tag = args[i]
 92			} else {
 93				return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
 94			}
 95		}
 96	}
 97	if path == "" || tag == "" {
 98		return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
 99	}
100	repo, code := resolveRepo(c, path, policy.CanWrite)
101	if code >= 0 {
102		return code
103	}
104	if code := refuseArchived(c, repo); code >= 0 {
105		return code
106	}
107	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
108	if _, err := gitutil.ResolveRef(dir, "refs/tags/"+tag); err != nil {
109		return c.fail(protocol.ExitNotFound, "no tag %q in %s — push the tag first", tag, repo.Path())
110	}
111	body, err := bodyFrom(c, notes, file)
112	if err != nil {
113		return c.fail(protocol.ExitUsage, "%v", err)
114	}
115	if title == "" {
116		title = tag
117	}
118	if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID); err != nil {
119		return c.fail(protocol.ExitUsage, "%v", err)
120	}
121	c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
122	return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
123		fmt.Fprintf(w, "created release %s on %s\n", tag, repo.Path())
124	})
125}
126
127type assetOut struct {
128	Name   string `json:"name"`
129	Size   int64  `json:"size"`
130	SHA256 string `json:"sha256"`
131}
132
133type releaseOut struct {
134	Tag       string     `json:"tag"`
135	Title     string     `json:"title"`
136	Notes     string     `json:"notes,omitempty"`
137	Author    string     `json:"author,omitempty"`
138	CreatedAt string     `json:"created_at"`
139	Assets    []assetOut `json:"assets,omitempty"`
140}
141
142func releaseToOut(r store.Release, withNotes bool) releaseOut {
143	o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
144	if withNotes {
145		o.Notes = r.Notes
146	}
147	for _, a := range r.Assets {
148		o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
149	}
150	return o
151}
152
153func runReleaseEdit(c *Ctx, args []string) int {
154	const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]"
155	var path, tag, title, notes, file string
156	var setTitle, setNotes bool
157	for i := 0; i < len(args); i++ {
158		switch args[i] {
159		case "--title", "--notes", "--file":
160			if i+1 >= len(args) {
161				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
162			}
163			switch args[i] {
164			case "--title":
165				title, setTitle = args[i+1], true
166			case "--notes":
167				notes, setNotes = args[i+1], true
168			case "--file":
169				file, setNotes = args[i+1], true
170			}
171			i++
172		default:
173			if path == "" {
174				path = args[i]
175			} else if tag == "" {
176				tag = args[i]
177			} else {
178				return c.fail(protocol.ExitUsage, usage)
179			}
180		}
181	}
182	if path == "" || tag == "" || (!setTitle && !setNotes) {
183		return c.fail(protocol.ExitUsage, usage)
184	}
185	repo, code := resolveRepo(c, path, policy.CanWrite)
186	if code >= 0 {
187		return code
188	}
189	if code := refuseArchived(c, repo); code >= 0 {
190		return code
191	}
192	rel, err := c.Store.ReleaseByTag(repo.ID, tag)
193	if err != nil {
194		return c.fail(protocol.ExitNotFound, "no release %q in %s", tag, repo.Path())
195	}
196	// Absent flags keep what the release already says.
197	if !setTitle {
198		title = rel.Title
199	} else if title == "" {
200		title = tag
201	}
202	body := rel.Notes
203	if setNotes {
204		if body, err = bodyFrom(c, notes, file); err != nil {
205			return c.fail(protocol.ExitUsage, "%v", err)
206		}
207	}
208	if err := c.Store.UpdateRelease(repo.ID, tag, title, body); err != nil {
209		return c.fail(protocol.ExitFailure, "%v", err)
210	}
211	return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
212		fmt.Fprintf(w, "updated release %s\n", tag)
213	})
214}
215
216func runReleaseList(c *Ctx, args []string) int {
217	if len(args) != 1 {
218		return c.fail(protocol.ExitUsage, "usage: release list <owner/name>")
219	}
220	repo, code := resolveRepo(c, args[0], policy.CanRead)
221	if code >= 0 {
222		return code
223	}
224	rels, err := c.Store.ListReleases(repo.ID)
225	if err != nil {
226		return c.fail(protocol.ExitFailure, "%v", err)
227	}
228	var ds []releaseOut
229	for _, r := range rels {
230		ds = append(ds, releaseToOut(r, false))
231	}
232	return c.emit(ds, func(w io.Writer) {
233		for _, d := range ds {
234			fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, d.Title, len(d.Assets))
235		}
236	})
237}
238
239func runReleaseShow(c *Ctx, args []string) int {
240	_, rel, code := releaseRef(c, args, policy.CanRead)
241	if code >= 0 {
242		return code
243	}
244	d := releaseToOut(rel, true)
245	return c.emit(d, func(w io.Writer) {
246		fmt.Fprintf(w, "%s\t%s\tby %s on %s\n", d.Tag, d.Title, d.Author, d.CreatedAt)
247		if d.Notes != "" {
248			fmt.Fprintf(w, "\n%s\n", d.Notes)
249		}
250		for _, a := range d.Assets {
251			fmt.Fprintf(w, "%s\t%d\t%s\n", a.Name, a.Size, a.SHA256)
252		}
253	})
254}
255
256func runReleaseDelete(c *Ctx, args []string) int {
257	var rest []string
258	var yes bool
259	for _, a := range args {
260		if a == "--yes" {
261			yes = true
262		} else {
263			rest = append(rest, a)
264		}
265	}
266	repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
267	if code >= 0 {
268		return code
269	}
270	if !yes {
271		return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
272	}
273	if err := c.Store.DeleteRelease(rel.ID); err != nil {
274		return c.fail(protocol.ExitFailure, "%v", err)
275	}
276	os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
277	return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
278		fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
279	})
280}
281
282func runAssetAdd(c *Ctx, args []string) int {
283	if len(args) != 3 {
284		return c.fail(protocol.ExitUsage, "usage: release asset add <owner/name> <tag> <filename> < file")
285	}
286	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
287	if code >= 0 {
288		return code
289	}
290	if code := refuseArchived(c, repo); code >= 0 {
291		return code
292	}
293	name := args[2]
294	if !assetNamePat.MatchString(name) {
295		return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
296	}
297	dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
298	if err := os.MkdirAll(dir, 0o750); err != nil {
299		return c.fail(protocol.ExitFailure, "%v", err)
300	}
301	tmp, err := os.CreateTemp(dir, ".upload-*")
302	if err != nil {
303		return c.fail(protocol.ExitFailure, "%v", err)
304	}
305	defer os.Remove(tmp.Name())
306	h := sha256.New()
307	limit := c.Cfg.Limits.MaxAssetBytes
308	n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
309	if err != nil {
310		return c.fail(protocol.ExitFailure, "reading asset: %v", err)
311	}
312	if n > limit {
313		return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
314	}
315	if n == 0 {
316		return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
317	}
318	if err := tmp.Close(); err != nil {
319		return c.fail(protocol.ExitFailure, "%v", err)
320	}
321	sum := hex.EncodeToString(h.Sum(nil))
322	if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
323		return c.fail(protocol.ExitUsage, "%v", err)
324	}
325	if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
326		c.Store.RemoveReleaseAsset(rel.ID, name)
327		return c.fail(protocol.ExitFailure, "%v", err)
328	}
329	return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
330		fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
331	})
332}
333
334func runAssetGet(c *Ctx, args []string) int {
335	if len(args) != 3 {
336		return c.fail(protocol.ExitUsage, "usage: release asset get <owner/name> <tag> <filename> > file")
337	}
338	repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
339	if code >= 0 {
340		return code
341	}
342	name := args[2]
343	if !assetNamePat.MatchString(name) {
344		return c.fail(protocol.ExitNotFound, "no asset %q", name)
345	}
346	f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
347	if err != nil {
348		return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
349	}
350	defer f.Close()
351	if _, err := io.Copy(c.Stdout, f); err != nil {
352		return protocol.ExitFailure
353	}
354	return protocol.ExitOK
355}
356
357func runAssetRemove(c *Ctx, args []string) int {
358	if len(args) != 3 {
359		return c.fail(protocol.ExitUsage, "usage: release asset remove <owner/name> <tag> <filename>")
360	}
361	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
362	if code >= 0 {
363		return code
364	}
365	if code := refuseArchived(c, repo); code >= 0 {
366		return code
367	}
368	name := args[2]
369	if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
370		if errors.Is(err, store.ErrNotFound) {
371			return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
372		}
373		return c.fail(protocol.ExitFailure, "%v", err)
374	}
375	if assetNamePat.MatchString(name) {
376		os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
377	}
378	return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
379		fmt.Fprintf(w, "removed %s\n", name)
380	})
381}