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