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