internal/control/release.go

b347d6c8c464e3c965455795f4e22e0aaeed0652
gitbay/internal/control/release.go history · blame · raw

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