rin
Public Open-source lightweight git hosting.
releases.go · 435 lines · 13.3 KB
Raw
1 package handlers
2
3 import (
4 "fmt"
5 "html/template"
6 "io"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11
12 "github.com/gofiber/fiber/v2"
13
14 "polar/internal/git"
15 "polar/internal/repo"
16 "polar/internal/web/middleware"
17 "polar/internal/web/views"
18 )
19
20 const (
21 maxReleaseAssets = 20
22 maxReleaseAssetSize = 100 << 20 // 100 MB per file (matches the server body limit)
23 )
24
25 type ReleaseViewItem struct {
26 repo.Release
27 BodyHTML template.HTML
28 Assets []repo.ReleaseAsset
29 }
30
31 func (h *Handler) RepoReleasesPage(c *fiber.Ctx) error {
32 r, diskPath, err := h.loadAuthorizedRepo(c, false)
33 if err != nil {
34 return nil
35 }
36 if !r.EnableReleases {
37 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
38 }
39
40 releases, _ := h.repoSvc.ListReleases(c.UserContext(), r.ID)
41 assetsByRelease, _ := h.repoSvc.ListReleaseAssetsByRepo(c.UserContext(), r.ID)
42 tags, _ := git.ListTags(c.UserContext(), diskPath)
43
44 var releaseItems []ReleaseViewItem
45 for _, rel := range releases {
46 rendered := views.RenderMarkdown([]byte(rel.Body))
47 releaseItems = append(releaseItems, ReleaseViewItem{
48 Release: rel,
49 BodyHTML: rendered,
50 Assets: assetsByRelease[rel.ID],
51 })
52 }
53
54 viewer := middleware.CurrentUser(c)
55 data := h.baseData(c, h.tr(c, "releases.title")+" · "+r.OwnerUsername+"/"+r.Name)
56 data["Repo"] = r
57 data["Ref"] = r.DefaultBranch
58 data["ActiveTab"] = "releases"
59 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
60 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
61 data["Releases"] = releaseItems
62 data["TagCount"] = len(tags)
63
64 return h.render(c, fiber.StatusOK, "releases", data)
65 }
66
67 func (h *Handler) RepoNewReleasePage(c *fiber.Ctx) error {
68 r, diskPath, err := h.loadAuthorizedRepo(c, true)
69 if err != nil {
70 return nil
71 }
72 if !r.EnableReleases {
73 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
74 }
75
76 tags, _ := git.ListTags(c.UserContext(), diskPath)
77 branches, _ := git.ListBranches(c.UserContext(), diskPath)
78 selectedTag := strings.TrimSpace(c.Query("tag"))
79
80 viewer := middleware.CurrentUser(c)
81 data := h.baseData(c, h.tr(c, "releases.new")+" · "+r.OwnerUsername+"/"+r.Name)
82 data["Repo"] = r
83 data["Ref"] = r.DefaultBranch
84 data["ActiveTab"] = "releases"
85 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
86 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
87 data["Tags"] = tags
88 data["Branches"] = branches
89 data["SelectedTag"] = selectedTag
90
91 return h.render(c, fiber.StatusOK, "release_new", data)
92 }
93
94 type releaseAssetUpload struct {
95 name string
96 data []byte
97 contentType string
98 }
99
100 // readReleaseAssetUploads parses the multipart body and returns the files to
101 // attach. It fails permanently (with a flash) when limits are exceeded.
102 func (h *Handler) readReleaseAssetUploads(c *fiber.Ctx) ([]releaseAssetUpload, error) {
103 form, err := c.MultipartForm()
104 if err != nil || form == nil {
105 return nil, nil
106 }
107 headers := form.File["assets"]
108 if len(headers) == 0 {
109 return nil, nil
110 }
111 if len(headers) > maxReleaseAssets {
112 return nil, fmt.Errorf("too many")
113 }
114
115 uploads := make([]releaseAssetUpload, 0, len(headers))
116 for _, fh := range headers {
117 if fh == nil || fh.Size <= 0 {
118 continue
119 }
120 if fh.Size > maxReleaseAssetSize {
121 return nil, fmt.Errorf("too big")
122 }
123 f, oerr := fh.Open()
124 if oerr != nil {
125 continue
126 }
127 data, rerr := io.ReadAll(io.LimitReader(f, maxReleaseAssetSize+1))
128 f.Close()
129 if rerr != nil {
130 continue
131 }
132 if int64(len(data)) > maxReleaseAssetSize {
133 return nil, fmt.Errorf("too big")
134 }
135 uploads = append(uploads, releaseAssetUpload{
136 name: fh.Filename,
137 data: data,
138 contentType: fh.Header.Get("Content-Type"),
139 })
140 }
141 return uploads, nil
142 }
143
144 func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error {
145 r, diskPath, err := h.loadAuthorizedRepo(c, true)
146 if err != nil {
147 return nil
148 }
149 if !r.EnableReleases {
150 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
151 }
152
153 backTo := fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name)
154 u := middleware.CurrentUser(c)
155 tagName := strings.TrimSpace(c.FormValue("tag_name"))
156 title := strings.TrimSpace(c.FormValue("title"))
157 body := strings.TrimSpace(c.FormValue("body"))
158 targetRef := strings.TrimSpace(c.FormValue("target_ref"))
159 if targetRef == "" {
160 targetRef = r.DefaultBranch
161 }
162 isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1"
163
164 if tagName == "" {
165 h.setFlashT(c, "flash.release_tag_empty")
166 return c.Redirect(backTo, fiber.StatusSeeOther)
167 }
168
169 uploads, uerr := h.readReleaseAssetUploads(c)
170 if uerr != nil {
171 switch uerr.Error() {
172 case "too many":
173 h.setFlashT(c, "flash.release_asset_too_many", maxReleaseAssets)
174 default:
175 h.setFlashT(c, "flash.release_asset_too_big")
176 }
177 return c.Redirect(backTo, fiber.StatusSeeOther)
178 }
179
180 // Create git tag if it doesn't exist yet
181 existingTags, _ := git.ListTags(c.UserContext(), diskPath)
182 tagExists := false
183 for _, t := range existingTags {
184 if t == tagName {
185 tagExists = true
186 break
187 }
188 }
189 if !tagExists {
190 if err := git.CreateTag(c.UserContext(), diskPath, tagName, targetRef, title); err != nil {
191 h.setFlashT(c, "flash.release_tag_failed", err.Error())
192 return c.Redirect(backTo, fiber.StatusSeeOther)
193 }
194 }
195
196 rel, err := h.repoSvc.CreateRelease(c.UserContext(), r.ID, u.ID, tagName, title, body, false, isPrerelease)
197 if err != nil {
198 h.setFlashT(c, "flash.release_create_failed", err.Error())
199 return c.Redirect(backTo, fiber.StatusSeeOther)
200 }
201
202 saved, failed := 0, 0
203 for _, up := range uploads {
204 if _, err := h.repoSvc.SaveReleaseAsset(c.UserContext(), r, rel.ID, u.ID, up.name, up.data, up.contentType); err != nil {
205 failed++
206 continue
207 }
208 saved++
209 }
210
211 h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID)
212 switch {
213 case failed > 0:
214 h.setFlashT(c, "flash.release_published_assets_failed", tagName)
215 case saved > 0:
216 h.setFlashT(c, "flash.release_published_with_assets", tagName, saved)
217 default:
218 h.setFlashT(c, "flash.release_published", tagName)
219 }
220 return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
221 }
222
223 // RepoReleaseAssetDownload streams an uploaded release asset.
224 // GET /:owner/:repo/releases/:id/assets/:assetID/download
225 func (h *Handler) RepoReleaseAssetDownload(c *fiber.Ctx) error {
226 r, _, err := h.loadAuthorizedRepo(c, false)
227 if err != nil {
228 return nil
229 }
230 if !r.EnableReleases {
231 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
232 }
233
234 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
235 assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64)
236 if relID <= 0 || assetID <= 0 {
237 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
238 }
239 if _, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID); err != nil {
240 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
241 }
242
243 asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID)
244 if err != nil || asset.ReleaseID != relID {
245 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
246 }
247
248 dir, err := h.repoSvc.ReleaseAssetsDir(r.OwnerUsername, r.Name, relID)
249 if err != nil {
250 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
251 }
252 f, err := os.Open(filepath.Join(dir, asset.StoredName))
253 if err != nil {
254 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
255 }
256 st, err := f.Stat()
257 if err != nil || st.IsDir() {
258 f.Close()
259 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
260 }
261
262 h.repoSvc.IncrementReleaseAssetDownloads(c.UserContext(), asset.ID)
263 return serveReleaseAssetFile(c, f, st.Size(), asset.ContentType, asset.Filename)
264 }
265
266 // serveReleaseAssetFile streams an open file with Content-Disposition set and
267 // support for a single HTTP Range request. The file is streamed (not served
268 // through the fasthttp FS cache) so deleting an asset takes effect immediately.
269 func serveReleaseAssetFile(c *fiber.Ctx, f *os.File, size int64, contentType, filename string) error {
270 c.Set(fiber.HeaderContentType, contentType)
271 c.Set(fiber.HeaderContentDisposition, downloadDisposition(filename))
272 c.Set(fiber.HeaderAcceptRanges, "bytes")
273
274 rangeHeader := strings.TrimSpace(c.Get(fiber.HeaderRange))
275 if rangeHeader == "" {
276 return c.SendStream(f, int(size))
277 }
278
279 start, length, ok := parseByteRange(rangeHeader, size)
280 if !ok {
281 f.Close()
282 c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes */%d", size))
283 return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
284 }
285 if _, err := f.Seek(start, io.SeekStart); err != nil {
286 f.Close()
287 return c.SendStatus(fiber.StatusInternalServerError)
288 }
289 c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, size))
290 c.Status(fiber.StatusPartialContent)
291 return c.SendStream(&closingLimitedReader{r: io.LimitReader(f, length), closer: f}, int(length))
292 }
293
294 type closingLimitedReader struct {
295 r io.Reader
296 closer io.Closer
297 }
298
299 func (l *closingLimitedReader) Read(p []byte) (int, error) { return l.r.Read(p) }
300 func (l *closingLimitedReader) Close() error { return l.closer.Close() }
301
302 // parseByteRange parses a single "bytes=start-end" range against a known size.
303 func parseByteRange(header string, size int64) (int64, int64, bool) {
304 if size <= 0 || !strings.HasPrefix(header, "bytes=") {
305 return 0, 0, false
306 }
307 spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes="))
308 if strings.Contains(spec, ",") {
309 return 0, 0, false
310 }
311 startStr, endStr, ok := strings.Cut(spec, "-")
312 if !ok {
313 return 0, 0, false
314 }
315 startStr, endStr = strings.TrimSpace(startStr), strings.TrimSpace(endStr)
316
317 var start, end int64
318 switch {
319 case startStr == "":
320 n, err := strconv.ParseInt(endStr, 10, 64)
321 if err != nil || n <= 0 {
322 return 0, 0, false
323 }
324 if n > size {
325 n = size
326 }
327 start, end = size-n, size-1
328 case endStr == "":
329 s, err := strconv.ParseInt(startStr, 10, 64)
330 if err != nil || s < 0 || s >= size {
331 return 0, 0, false
332 }
333 start, end = s, size-1
334 default:
335 s, err1 := strconv.ParseInt(startStr, 10, 64)
336 e, err2 := strconv.ParseInt(endStr, 10, 64)
337 if err1 != nil || err2 != nil || s < 0 || s > e || s >= size {
338 return 0, 0, false
339 }
340 if e >= size {
341 e = size - 1
342 }
343 start, end = s, e
344 }
345 return start, end - start + 1, true
346 }
347
348 // downloadDisposition builds an RFC 6266/5987 attachment header for a filename
349 // that may contain non-ASCII characters.
350 func downloadDisposition(name string) string {
351 fallback := strings.Map(func(r rune) rune {
352 if r < 32 || r == '"' || r == '\\' || r > 126 {
353 return '_'
354 }
355 return r
356 }, name)
357 fallback = strings.TrimSpace(fallback)
358 if fallback == "" {
359 fallback = "download"
360 }
361
362 var encoded strings.Builder
363 for i := 0; i < len(name); i++ {
364 ch := name[i]
365 if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
366 ch == '!' || ch == '#' || ch == '$' || ch == '&' || ch == '+' || ch == '-' ||
367 ch == '.' || ch == '^' || ch == '_' || ch == '`' || ch == '|' || ch == '~' {
368 encoded.WriteByte(ch)
369 } else {
370 fmt.Fprintf(&encoded, "%%%02X", ch)
371 }
372 }
373 return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", fallback, encoded.String())
374 }
375
376 // RepoDeleteReleaseAssetSubmit removes a single uploaded asset.
377 // POST /:owner/:repo/releases/:id/assets/:assetID/delete
378 func (h *Handler) RepoDeleteReleaseAssetSubmit(c *fiber.Ctx) error {
379 r, _, err := h.loadAuthorizedRepo(c, true)
380 if err != nil {
381 return nil
382 }
383 if !r.EnableReleases {
384 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
385 }
386
387 backTo := fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name)
388 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
389 assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64)
390
391 rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
392 if err != nil {
393 return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
394 }
395 backTo += "#" + rel.TagName
396
397 asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID)
398 if err != nil || asset.ReleaseID != relID {
399 h.setFlashT(c, "flash.release_asset_delete_failed", "not found")
400 return c.Redirect(backTo, fiber.StatusSeeOther)
401 }
402
403 if err := h.repoSvc.DeleteReleaseAsset(c.UserContext(), r, asset); err != nil {
404 h.setFlashT(c, "flash.release_asset_delete_failed", err.Error())
405 } else {
406 h.setFlashT(c, "flash.release_asset_deleted")
407 }
408 return c.Redirect(backTo, fiber.StatusSeeOther)
409 }
410
411 func (h *Handler) RepoDeleteReleaseSubmit(c *fiber.Ctx) error {
412 r, _, err := h.loadAuthorizedRepo(c, true)
413 if err != nil {
414 return nil
415 }
416 if !r.EnableReleases {
417 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
418 }
419
420 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
421
422 rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
423 if err != nil {
424 return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
425 }
426
427 h.repoSvc.RemoveReleaseAssets(r, rel.ID)
428 if err := h.repoSvc.DeleteRelease(c.UserContext(), rel.ID); err != nil {
429 h.setFlashT(c, "flash.release_delete_failed", err.Error())
430 } else {
431 h.setFlashT(c, "flash.release_deleted")
432 }
433
434 return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
435 }
436