rin
Public Open-source lightweight git hosting.
releases.go · 532 lines · 16.6 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 // RepoEditReleasePage renders the editor for an existing release.
224 // GET /:owner/:repo/releases/:id/edit
225 func (h *Handler) RepoEditReleasePage(c *fiber.Ctx) error {
226 r, _, err := h.loadAuthorizedRepo(c, true)
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 rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
236 if err != nil {
237 return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
238 }
239
240 assetsByRelease, _ := h.repoSvc.ListReleaseAssetsByRepo(c.UserContext(), r.ID)
241
242 viewer := middleware.CurrentUser(c)
243 data := h.baseData(c, h.tr(c, "releases.edit")+" · "+r.OwnerUsername+"/"+r.Name)
244 data["Repo"] = r
245 data["Ref"] = r.DefaultBranch
246 data["ActiveTab"] = "releases"
247 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
248 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
249 data["Release"] = rel
250 data["Assets"] = assetsByRelease[rel.ID]
251
252 return h.render(c, fiber.StatusOK, "release_edit", data)
253 }
254
255 // RepoEditReleaseSubmit saves edited metadata and any newly attached files.
256 // POST /:owner/:repo/releases/:id/edit
257 func (h *Handler) RepoEditReleaseSubmit(c *fiber.Ctx) error {
258 r, _, err := h.loadAuthorizedRepo(c, true)
259 if err != nil {
260 return nil
261 }
262 if !r.EnableReleases {
263 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
264 }
265
266 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
267 backTo := fmt.Sprintf("/%s/%s/releases/%d/edit", r.OwnerUsername, r.Name, relID)
268
269 rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
270 if err != nil {
271 return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
272 }
273
274 uploads, uerr := h.readReleaseAssetUploads(c)
275 if uerr != nil {
276 switch uerr.Error() {
277 case "too many":
278 h.setFlashT(c, "flash.release_asset_too_many", maxReleaseAssets)
279 default:
280 h.setFlashT(c, "flash.release_asset_too_big")
281 }
282 return c.Redirect(backTo, fiber.StatusSeeOther)
283 }
284
285 u := middleware.CurrentUser(c)
286 title := strings.TrimSpace(c.FormValue("title"))
287 body := strings.TrimSpace(c.FormValue("body"))
288 isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1"
289
290 if err := h.repoSvc.UpdateRelease(c.UserContext(), rel.ID, title, body, isPrerelease); err != nil {
291 h.setFlashT(c, "flash.release_update_failed", err.Error())
292 return c.Redirect(backTo, fiber.StatusSeeOther)
293 }
294
295 saved, failed := 0, 0
296 for _, up := range uploads {
297 if _, err := h.repoSvc.SaveReleaseAsset(c.UserContext(), r, rel.ID, u.ID, up.name, up.data, up.contentType); err != nil {
298 failed++
299 continue
300 }
301 saved++
302 }
303
304 h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID)
305 switch {
306 case failed > 0:
307 h.setFlashT(c, "flash.release_updated_assets_failed", rel.TagName)
308 case saved > 0:
309 h.setFlashT(c, "flash.release_updated_with_assets", rel.TagName, saved)
310 default:
311 h.setFlashT(c, "flash.release_updated", rel.TagName)
312 }
313
314 return c.Redirect(fmt.Sprintf("/%s/%s/releases#%s", r.OwnerUsername, r.Name, rel.TagName), fiber.StatusSeeOther)
315 }
316
317 // RepoReleaseAssetDownload streams an uploaded release asset.
318 // GET /:owner/:repo/releases/:id/assets/:assetID/download
319 func (h *Handler) RepoReleaseAssetDownload(c *fiber.Ctx) error {
320 r, _, err := h.loadAuthorizedRepo(c, false)
321 if err != nil {
322 return nil
323 }
324 if !r.EnableReleases {
325 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
326 }
327
328 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
329 assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64)
330 if relID <= 0 || assetID <= 0 {
331 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
332 }
333 if _, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID); err != nil {
334 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
335 }
336
337 asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID)
338 if err != nil || asset.ReleaseID != relID {
339 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
340 }
341
342 dir, err := h.repoSvc.ReleaseAssetsDir(r.OwnerUsername, r.Name, relID)
343 if err != nil {
344 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
345 }
346 f, err := os.Open(filepath.Join(dir, asset.StoredName))
347 if err != nil {
348 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
349 }
350 st, err := f.Stat()
351 if err != nil || st.IsDir() {
352 f.Close()
353 return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
354 }
355
356 h.repoSvc.IncrementReleaseAssetDownloads(c.UserContext(), asset.ID)
357 return serveReleaseAssetFile(c, f, st.Size(), asset.ContentType, asset.Filename)
358 }
359
360 // serveReleaseAssetFile streams an open file with Content-Disposition set and
361 // support for a single HTTP Range request. The file is streamed (not served
362 // through the fasthttp FS cache) so deleting an asset takes effect immediately.
363 func serveReleaseAssetFile(c *fiber.Ctx, f *os.File, size int64, contentType, filename string) error {
364 c.Set(fiber.HeaderContentType, contentType)
365 c.Set(fiber.HeaderContentDisposition, downloadDisposition(filename))
366 c.Set(fiber.HeaderAcceptRanges, "bytes")
367
368 rangeHeader := strings.TrimSpace(c.Get(fiber.HeaderRange))
369 if rangeHeader == "" {
370 return c.SendStream(f, int(size))
371 }
372
373 start, length, ok := parseByteRange(rangeHeader, size)
374 if !ok {
375 f.Close()
376 c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes */%d", size))
377 return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
378 }
379 if _, err := f.Seek(start, io.SeekStart); err != nil {
380 f.Close()
381 return c.SendStatus(fiber.StatusInternalServerError)
382 }
383 c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, size))
384 c.Status(fiber.StatusPartialContent)
385 return c.SendStream(&closingLimitedReader{r: io.LimitReader(f, length), closer: f}, int(length))
386 }
387
388 type closingLimitedReader struct {
389 r io.Reader
390 closer io.Closer
391 }
392
393 func (l *closingLimitedReader) Read(p []byte) (int, error) { return l.r.Read(p) }
394 func (l *closingLimitedReader) Close() error { return l.closer.Close() }
395
396 // parseByteRange parses a single "bytes=start-end" range against a known size.
397 func parseByteRange(header string, size int64) (int64, int64, bool) {
398 if size <= 0 || !strings.HasPrefix(header, "bytes=") {
399 return 0, 0, false
400 }
401 spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes="))
402 if strings.Contains(spec, ",") {
403 return 0, 0, false
404 }
405 startStr, endStr, ok := strings.Cut(spec, "-")
406 if !ok {
407 return 0, 0, false
408 }
409 startStr, endStr = strings.TrimSpace(startStr), strings.TrimSpace(endStr)
410
411 var start, end int64
412 switch {
413 case startStr == "":
414 n, err := strconv.ParseInt(endStr, 10, 64)
415 if err != nil || n <= 0 {
416 return 0, 0, false
417 }
418 if n > size {
419 n = size
420 }
421 start, end = size-n, size-1
422 case endStr == "":
423 s, err := strconv.ParseInt(startStr, 10, 64)
424 if err != nil || s < 0 || s >= size {
425 return 0, 0, false
426 }
427 start, end = s, size-1
428 default:
429 s, err1 := strconv.ParseInt(startStr, 10, 64)
430 e, err2 := strconv.ParseInt(endStr, 10, 64)
431 if err1 != nil || err2 != nil || s < 0 || s > e || s >= size {
432 return 0, 0, false
433 }
434 if e >= size {
435 e = size - 1
436 }
437 start, end = s, e
438 }
439 return start, end - start + 1, true
440 }
441
442 // downloadDisposition builds an RFC 6266/5987 attachment header for a filename
443 // that may contain non-ASCII characters.
444 func downloadDisposition(name string) string {
445 fallback := strings.Map(func(r rune) rune {
446 if r < 32 || r == '"' || r == '\\' || r > 126 {
447 return '_'
448 }
449 return r
450 }, name)
451 fallback = strings.TrimSpace(fallback)
452 if fallback == "" {
453 fallback = "download"
454 }
455
456 var encoded strings.Builder
457 for i := 0; i < len(name); i++ {
458 ch := name[i]
459 if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
460 ch == '!' || ch == '#' || ch == '$' || ch == '&' || ch == '+' || ch == '-' ||
461 ch == '.' || ch == '^' || ch == '_' || ch == '`' || ch == '|' || ch == '~' {
462 encoded.WriteByte(ch)
463 } else {
464 fmt.Fprintf(&encoded, "%%%02X", ch)
465 }
466 }
467 return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", fallback, encoded.String())
468 }
469
470 // RepoDeleteReleaseAssetSubmit removes a single uploaded asset.
471 // POST /:owner/:repo/releases/:id/assets/:assetID/delete
472 func (h *Handler) RepoDeleteReleaseAssetSubmit(c *fiber.Ctx) error {
473 r, _, err := h.loadAuthorizedRepo(c, true)
474 if err != nil {
475 return nil
476 }
477 if !r.EnableReleases {
478 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
479 }
480
481 backTo := fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name)
482 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
483 assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64)
484
485 rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
486 if err != nil {
487 return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
488 }
489 backTo += "#" + rel.TagName
490 if next := strings.TrimSpace(c.FormValue("next")); strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
491 backTo = next
492 }
493
494 asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID)
495 if err != nil || asset.ReleaseID != relID {
496 h.setFlashT(c, "flash.release_asset_delete_failed", "not found")
497 return c.Redirect(backTo, fiber.StatusSeeOther)
498 }
499
500 if err := h.repoSvc.DeleteReleaseAsset(c.UserContext(), r, asset); err != nil {
501 h.setFlashT(c, "flash.release_asset_delete_failed", err.Error())
502 } else {
503 h.setFlashT(c, "flash.release_asset_deleted")
504 }
505 return c.Redirect(backTo, fiber.StatusSeeOther)
506 }
507
508 func (h *Handler) RepoDeleteReleaseSubmit(c *fiber.Ctx) error {
509 r, _, err := h.loadAuthorizedRepo(c, true)
510 if err != nil {
511 return nil
512 }
513 if !r.EnableReleases {
514 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
515 }
516
517 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
518
519 rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
520 if err != nil {
521 return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
522 }
523
524 h.repoSvc.RemoveReleaseAssets(r, rel.ID)
525 if err := h.repoSvc.DeleteRelease(c.UserContext(), rel.ID); err != nil {
526 h.setFlashT(c, "flash.release_delete_failed", err.Error())
527 } else {
528 h.setFlashT(c, "flash.release_deleted")
529 }
530
531 return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
532 }
533