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