internal/control/release.go

5460cca8a555d6d23646aebf992a3bdd82c8fee3
gitbay/internal/control/release.go history · blame · raw

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