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