internal/control/release.go

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

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