internal/control/release.go
367 lines · 12168 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.failInput(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.failInput(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.failInput(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.failInput(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 c.Store.RecordEvent(repo.ID, c.User.ID, "release.deleted", fmt.Sprintf(`{"tag":%q}`, rel.Tag))
263 return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
264 fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
265 })
266}
267
268func runAssetAdd(c *Ctx, args []string) int {
269 if len(args) != 3 {
270 return c.fail(protocol.ExitUsage, "usage: release asset add <owner/name> <tag> <filename> < file")
271 }
272 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
273 if code >= 0 {
274 return code
275 }
276 if code := refuseArchived(c, repo); code >= 0 {
277 return code
278 }
279 name := args[2]
280 if !assetNamePat.MatchString(name) {
281 return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
282 }
283 dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
284 if err := os.MkdirAll(dir, 0o750); err != nil {
285 return c.fail(protocol.ExitFailure, "%v", err)
286 }
287 tmp, err := os.CreateTemp(dir, ".upload-*")
288 if err != nil {
289 return c.fail(protocol.ExitFailure, "%v", err)
290 }
291 defer os.Remove(tmp.Name())
292 h := sha256.New()
293 limit := c.Cfg.Limits.MaxAssetBytes
294 n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
295 if err != nil {
296 return c.fail(protocol.ExitFailure, "reading asset: %v", err)
297 }
298 if n > limit {
299 return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
300 }
301 if n == 0 {
302 return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
303 }
304 if err := tmp.Close(); err != nil {
305 return c.fail(protocol.ExitFailure, "%v", err)
306 }
307 sum := hex.EncodeToString(h.Sum(nil))
308 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
309 return c.failErr(err)
310 }
311 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
312 c.Store.RemoveReleaseAsset(rel.ID, name)
313 return c.fail(protocol.ExitFailure, "%v", err)
314 }
315 return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
316 fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
317 })
318}
319
320func runAssetGet(c *Ctx, args []string) int {
321 if len(args) != 3 {
322 return c.fail(protocol.ExitUsage, "usage: release asset get <owner/name> <tag> <filename> > file")
323 }
324 repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
325 if code >= 0 {
326 return code
327 }
328 name := args[2]
329 if !assetNamePat.MatchString(name) {
330 return c.fail(protocol.ExitNotFound, "no asset %q", name)
331 }
332 f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
333 if err != nil {
334 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
335 }
336 defer f.Close()
337 if _, err := io.Copy(c.Stdout, f); err != nil {
338 return protocol.ExitFailure
339 }
340 return protocol.ExitOK
341}
342
343func runAssetRemove(c *Ctx, args []string) int {
344 if len(args) != 3 {
345 return c.fail(protocol.ExitUsage, "usage: release asset remove <owner/name> <tag> <filename>")
346 }
347 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
348 if code >= 0 {
349 return code
350 }
351 if code := refuseArchived(c, repo); code >= 0 {
352 return code
353 }
354 name := args[2]
355 if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
356 if errors.Is(err, store.ErrNotFound) {
357 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
358 }
359 return c.fail(protocol.ExitFailure, "%v", err)
360 }
361 if assetNamePat.MatchString(name) {
362 os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
363 }
364 return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
365 fmt.Fprintf(w, "removed %s\n", name)
366 })
367}