internal/control/release.go

2e62f52baae22d14056deefc922dc1b9ec9b7bc5
gitbay/internal/control/release.go history · blame · raw

315 lines · 10125 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", "list"},
 25		Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList})
 26	register(Command{Path: []string{"release", "show"},
 27		Summary: "show a release with assets: release show <owner/name> <tag>", ReadOnly: true, Run: runReleaseShow})
 28	register(Command{Path: []string{"release", "delete"},
 29		Summary: "delete a release and its assets: release delete <owner/name> <tag> --yes", Run: runReleaseDelete})
 30	register(Command{Path: []string{"release", "asset", "add"},
 31		Summary: "upload an asset from stdin: release asset add <owner/name> <tag> <filename> < file",
 32		ReadsStdin: true, Run: runAssetAdd})
 33	register(Command{Path: []string{"release", "asset", "get"},
 34		Summary: "write an asset to stdout: release asset get <owner/name> <tag> <filename> > file",
 35		ReadOnly: true, Run: runAssetGet})
 36	register(Command{Path: []string{"release", "asset", "remove"},
 37		Summary: "remove an asset: release asset remove <owner/name> <tag> <filename>", Run: runAssetRemove})
 38}
 39
 40var assetNamePat = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$`)
 41
 42// assetDir holds a release's uploaded files inside the bare repo directory,
 43// so backup, transfer, and delete all carry them automatically.
 44func assetDir(root string, repo store.Repo, releaseID int64) string {
 45	return filepath.Join(RepoDir(root, repo.OwnerName, repo.Name), "gitbay-releases", strconv.FormatInt(releaseID, 10))
 46}
 47
 48// releaseRef loads a release for "<owner/name> <tag>" with the permission.
 49func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Release, int) {
 50	if len(args) < 2 {
 51		return store.Repo{}, store.Release{}, c.fail(protocol.ExitUsage, "expected <owner/name> <tag>")
 52	}
 53	repo, code := resolveRepo(c, args[0], perm)
 54	if code >= 0 {
 55		return repo, store.Release{}, code
 56	}
 57	rel, err := c.Store.ReleaseByTag(repo.ID, args[1])
 58	if errors.Is(err, store.ErrNotFound) {
 59		return repo, rel, c.fail(protocol.ExitNotFound, "no release for tag %q in %s", args[1], repo.Path())
 60	}
 61	if err != nil {
 62		return repo, rel, c.fail(protocol.ExitFailure, "%v", err)
 63	}
 64	return repo, rel, -1
 65}
 66
 67func runReleaseCreate(c *Ctx, args []string) int {
 68	var path, tag, title, notes, file string
 69	for i := 0; i < len(args); i++ {
 70		switch args[i] {
 71		case "--title", "--notes", "--file":
 72			if i+1 >= len(args) {
 73				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 74			}
 75			switch args[i] {
 76			case "--title":
 77				title = args[i+1]
 78			case "--notes":
 79				notes = args[i+1]
 80			case "--file":
 81				file = args[i+1]
 82			}
 83			i++
 84		default:
 85			if path == "" {
 86				path = args[i]
 87			} else if tag == "" {
 88				tag = args[i]
 89			} else {
 90				return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
 91			}
 92		}
 93	}
 94	if path == "" || tag == "" {
 95		return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
 96	}
 97	repo, code := resolveRepo(c, path, policy.CanWrite)
 98	if code >= 0 {
 99		return code
100	}
101	if code := refuseArchived(c, repo); code >= 0 {
102		return code
103	}
104	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
105	if _, err := gitutil.ResolveRef(dir, "refs/tags/"+tag); err != nil {
106		return c.fail(protocol.ExitNotFound, "no tag %q in %s — push the tag first", tag, repo.Path())
107	}
108	body, err := bodyFrom(c, notes, file)
109	if err != nil {
110		return c.fail(protocol.ExitUsage, "%v", err)
111	}
112	if title == "" {
113		title = tag
114	}
115	if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID); err != nil {
116		return c.fail(protocol.ExitUsage, "%v", err)
117	}
118	c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
119	return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
120		fmt.Fprintf(w, "created release %s on %s\n", tag, repo.Path())
121	})
122}
123
124type assetOut struct {
125	Name   string `json:"name"`
126	Size   int64  `json:"size"`
127	SHA256 string `json:"sha256"`
128}
129
130type releaseOut struct {
131	Tag       string     `json:"tag"`
132	Title     string     `json:"title"`
133	Notes     string     `json:"notes,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	}
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 runReleaseList(c *Ctx, args []string) int {
151	if len(args) != 1 {
152		return c.fail(protocol.ExitUsage, "usage: release list <owner/name>")
153	}
154	repo, code := resolveRepo(c, args[0], policy.CanRead)
155	if code >= 0 {
156		return code
157	}
158	rels, err := c.Store.ListReleases(repo.ID)
159	if err != nil {
160		return c.fail(protocol.ExitFailure, "%v", err)
161	}
162	var ds []releaseOut
163	for _, r := range rels {
164		ds = append(ds, releaseToOut(r, false))
165	}
166	return c.emit(ds, func(w io.Writer) {
167		for _, d := range ds {
168			fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, d.Title, len(d.Assets))
169		}
170	})
171}
172
173func runReleaseShow(c *Ctx, args []string) int {
174	_, rel, code := releaseRef(c, args, policy.CanRead)
175	if code >= 0 {
176		return code
177	}
178	d := releaseToOut(rel, true)
179	return c.emit(d, func(w io.Writer) {
180		fmt.Fprintf(w, "%s\t%s\tby %s on %s\n", d.Tag, d.Title, d.Author, d.CreatedAt)
181		if d.Notes != "" {
182			fmt.Fprintf(w, "\n%s\n", d.Notes)
183		}
184		for _, a := range d.Assets {
185			fmt.Fprintf(w, "%s\t%d\t%s\n", a.Name, a.Size, a.SHA256)
186		}
187	})
188}
189
190func runReleaseDelete(c *Ctx, args []string) int {
191	var rest []string
192	var yes bool
193	for _, a := range args {
194		if a == "--yes" {
195			yes = true
196		} else {
197			rest = append(rest, a)
198		}
199	}
200	repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
201	if code >= 0 {
202		return code
203	}
204	if !yes {
205		return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
206	}
207	if err := c.Store.DeleteRelease(rel.ID); err != nil {
208		return c.fail(protocol.ExitFailure, "%v", err)
209	}
210	os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
211	return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
212		fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
213	})
214}
215
216func runAssetAdd(c *Ctx, args []string) int {
217	if len(args) != 3 {
218		return c.fail(protocol.ExitUsage, "usage: release asset add <owner/name> <tag> <filename> < file")
219	}
220	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
221	if code >= 0 {
222		return code
223	}
224	if code := refuseArchived(c, repo); code >= 0 {
225		return code
226	}
227	name := args[2]
228	if !assetNamePat.MatchString(name) {
229		return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
230	}
231	dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
232	if err := os.MkdirAll(dir, 0o750); err != nil {
233		return c.fail(protocol.ExitFailure, "%v", err)
234	}
235	tmp, err := os.CreateTemp(dir, ".upload-*")
236	if err != nil {
237		return c.fail(protocol.ExitFailure, "%v", err)
238	}
239	defer os.Remove(tmp.Name())
240	h := sha256.New()
241	limit := c.Cfg.Limits.MaxAssetBytes
242	n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
243	if err != nil {
244		return c.fail(protocol.ExitFailure, "reading asset: %v", err)
245	}
246	if n > limit {
247		return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
248	}
249	if n == 0 {
250		return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
251	}
252	if err := tmp.Close(); err != nil {
253		return c.fail(protocol.ExitFailure, "%v", err)
254	}
255	sum := hex.EncodeToString(h.Sum(nil))
256	if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
257		return c.fail(protocol.ExitUsage, "%v", err)
258	}
259	if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
260		c.Store.RemoveReleaseAsset(rel.ID, name)
261		return c.fail(protocol.ExitFailure, "%v", err)
262	}
263	return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
264		fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
265	})
266}
267
268func runAssetGet(c *Ctx, args []string) int {
269	if len(args) != 3 {
270		return c.fail(protocol.ExitUsage, "usage: release asset get <owner/name> <tag> <filename> > file")
271	}
272	repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
273	if code >= 0 {
274		return code
275	}
276	name := args[2]
277	if !assetNamePat.MatchString(name) {
278		return c.fail(protocol.ExitNotFound, "no asset %q", name)
279	}
280	f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
281	if err != nil {
282		return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
283	}
284	defer f.Close()
285	if _, err := io.Copy(c.Stdout, f); err != nil {
286		return protocol.ExitFailure
287	}
288	return protocol.ExitOK
289}
290
291func runAssetRemove(c *Ctx, args []string) int {
292	if len(args) != 3 {
293		return c.fail(protocol.ExitUsage, "usage: release asset remove <owner/name> <tag> <filename>")
294	}
295	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
296	if code >= 0 {
297		return code
298	}
299	if code := refuseArchived(c, repo); code >= 0 {
300		return code
301	}
302	name := args[2]
303	if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
304		if errors.Is(err, store.ErrNotFound) {
305			return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
306		}
307		return c.fail(protocol.ExitFailure, "%v", err)
308	}
309	if assetNamePat.MatchString(name) {
310		os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
311	}
312	return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
313		fmt.Fprintf(w, "removed %s\n", name)
314	})
315}