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