internal/control/release.go
365 lines · 11609 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 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}