package handlers import ( "fmt" "html/template" "io" "os" "path/filepath" "strconv" "strings" "github.com/gofiber/fiber/v2" "polar/internal/git" "polar/internal/repo" "polar/internal/web/middleware" "polar/internal/web/views" ) const ( maxReleaseAssets = 20 maxReleaseAssetSize = 100 << 20 // 100 MB per file (matches the server body limit) ) type ReleaseViewItem struct { repo.Release BodyHTML template.HTML Assets []repo.ReleaseAsset } func (h *Handler) RepoReleasesPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } releases, _ := h.repoSvc.ListReleases(c.UserContext(), r.ID) assetsByRelease, _ := h.repoSvc.ListReleaseAssetsByRepo(c.UserContext(), r.ID) tags, _ := git.ListTags(c.UserContext(), diskPath) var releaseItems []ReleaseViewItem for _, rel := range releases { rendered := views.RenderMarkdown([]byte(rel.Body)) releaseItems = append(releaseItems, ReleaseViewItem{ Release: rel, BodyHTML: rendered, Assets: assetsByRelease[rel.ID], }) } viewer := middleware.CurrentUser(c) data := h.baseData(c, h.tr(c, "releases.title")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "releases" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Releases"] = releaseItems data["TagCount"] = len(tags) return h.render(c, fiber.StatusOK, "releases", data) } func (h *Handler) RepoNewReleasePage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } tags, _ := git.ListTags(c.UserContext(), diskPath) branches, _ := git.ListBranches(c.UserContext(), diskPath) selectedTag := strings.TrimSpace(c.Query("tag")) viewer := middleware.CurrentUser(c) data := h.baseData(c, h.tr(c, "releases.new")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "releases" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Tags"] = tags data["Branches"] = branches data["SelectedTag"] = selectedTag return h.render(c, fiber.StatusOK, "release_new", data) } type releaseAssetUpload struct { name string data []byte contentType string } // readReleaseAssetUploads parses the multipart body and returns the files to // attach. It fails permanently (with a flash) when limits are exceeded. func (h *Handler) readReleaseAssetUploads(c *fiber.Ctx) ([]releaseAssetUpload, error) { form, err := c.MultipartForm() if err != nil || form == nil { return nil, nil } headers := form.File["assets"] if len(headers) == 0 { return nil, nil } if len(headers) > maxReleaseAssets { return nil, fmt.Errorf("too many") } uploads := make([]releaseAssetUpload, 0, len(headers)) for _, fh := range headers { if fh == nil || fh.Size <= 0 { continue } if fh.Size > maxReleaseAssetSize { return nil, fmt.Errorf("too big") } f, oerr := fh.Open() if oerr != nil { continue } data, rerr := io.ReadAll(io.LimitReader(f, maxReleaseAssetSize+1)) f.Close() if rerr != nil { continue } if int64(len(data)) > maxReleaseAssetSize { return nil, fmt.Errorf("too big") } uploads = append(uploads, releaseAssetUpload{ name: fh.Filename, data: data, contentType: fh.Header.Get("Content-Type"), }) } return uploads, nil } func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } backTo := fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name) u := middleware.CurrentUser(c) tagName := strings.TrimSpace(c.FormValue("tag_name")) title := strings.TrimSpace(c.FormValue("title")) body := strings.TrimSpace(c.FormValue("body")) targetRef := strings.TrimSpace(c.FormValue("target_ref")) if targetRef == "" { targetRef = r.DefaultBranch } isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1" if tagName == "" { h.setFlashT(c, "flash.release_tag_empty") return c.Redirect(backTo, fiber.StatusSeeOther) } uploads, uerr := h.readReleaseAssetUploads(c) if uerr != nil { switch uerr.Error() { case "too many": h.setFlashT(c, "flash.release_asset_too_many", maxReleaseAssets) default: h.setFlashT(c, "flash.release_asset_too_big") } return c.Redirect(backTo, fiber.StatusSeeOther) } // Create git tag if it doesn't exist yet existingTags, _ := git.ListTags(c.UserContext(), diskPath) tagExists := false for _, t := range existingTags { if t == tagName { tagExists = true break } } if !tagExists { if err := git.CreateTag(c.UserContext(), diskPath, tagName, targetRef, title); err != nil { h.setFlashT(c, "flash.release_tag_failed", err.Error()) return c.Redirect(backTo, fiber.StatusSeeOther) } } rel, err := h.repoSvc.CreateRelease(c.UserContext(), r.ID, u.ID, tagName, title, body, false, isPrerelease) if err != nil { h.setFlashT(c, "flash.release_create_failed", err.Error()) return c.Redirect(backTo, fiber.StatusSeeOther) } saved, failed := 0, 0 for _, up := range uploads { if _, err := h.repoSvc.SaveReleaseAsset(c.UserContext(), r, rel.ID, u.ID, up.name, up.data, up.contentType); err != nil { failed++ continue } saved++ } h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID) switch { case failed > 0: h.setFlashT(c, "flash.release_published_assets_failed", tagName) case saved > 0: h.setFlashT(c, "flash.release_published_with_assets", tagName, saved) default: h.setFlashT(c, "flash.release_published", tagName) } return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } // RepoEditReleasePage renders the editor for an existing release. // GET /:owner/:repo/releases/:id/edit func (h *Handler) RepoEditReleasePage(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc") } assetsByRelease, _ := h.repoSvc.ListReleaseAssetsByRepo(c.UserContext(), r.ID) viewer := middleware.CurrentUser(c) data := h.baseData(c, h.tr(c, "releases.edit")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "releases" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Release"] = rel data["Assets"] = assetsByRelease[rel.ID] return h.render(c, fiber.StatusOK, "release_edit", data) } // RepoEditReleaseSubmit saves edited metadata and any newly attached files. // POST /:owner/:repo/releases/:id/edit func (h *Handler) RepoEditReleaseSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) backTo := fmt.Sprintf("/%s/%s/releases/%d/edit", r.OwnerUsername, r.Name, relID) rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc") } uploads, uerr := h.readReleaseAssetUploads(c) if uerr != nil { switch uerr.Error() { case "too many": h.setFlashT(c, "flash.release_asset_too_many", maxReleaseAssets) default: h.setFlashT(c, "flash.release_asset_too_big") } return c.Redirect(backTo, fiber.StatusSeeOther) } u := middleware.CurrentUser(c) title := strings.TrimSpace(c.FormValue("title")) body := strings.TrimSpace(c.FormValue("body")) isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1" if err := h.repoSvc.UpdateRelease(c.UserContext(), rel.ID, title, body, isPrerelease); err != nil { h.setFlashT(c, "flash.release_update_failed", err.Error()) return c.Redirect(backTo, fiber.StatusSeeOther) } saved, failed := 0, 0 for _, up := range uploads { if _, err := h.repoSvc.SaveReleaseAsset(c.UserContext(), r, rel.ID, u.ID, up.name, up.data, up.contentType); err != nil { failed++ continue } saved++ } h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID) switch { case failed > 0: h.setFlashT(c, "flash.release_updated_assets_failed", rel.TagName) case saved > 0: h.setFlashT(c, "flash.release_updated_with_assets", rel.TagName, saved) default: h.setFlashT(c, "flash.release_updated", rel.TagName) } return c.Redirect(fmt.Sprintf("/%s/%s/releases#%s", r.OwnerUsername, r.Name, rel.TagName), fiber.StatusSeeOther) } // RepoReleaseAssetDownload streams an uploaded release asset. // GET /:owner/:repo/releases/:id/assets/:assetID/download func (h *Handler) RepoReleaseAssetDownload(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64) if relID <= 0 || assetID <= 0 { return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc") } if _, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID); err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc") } asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID) if err != nil || asset.ReleaseID != relID { return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc") } dir, err := h.repoSvc.ReleaseAssetsDir(r.OwnerUsername, r.Name, relID) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc") } f, err := os.Open(filepath.Join(dir, asset.StoredName)) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc") } st, err := f.Stat() if err != nil || st.IsDir() { f.Close() return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc") } h.repoSvc.IncrementReleaseAssetDownloads(c.UserContext(), asset.ID) return serveReleaseAssetFile(c, f, st.Size(), asset.ContentType, asset.Filename) } // serveReleaseAssetFile streams an open file with Content-Disposition set and // support for a single HTTP Range request. The file is streamed (not served // through the fasthttp FS cache) so deleting an asset takes effect immediately. func serveReleaseAssetFile(c *fiber.Ctx, f *os.File, size int64, contentType, filename string) error { c.Set(fiber.HeaderContentType, contentType) c.Set(fiber.HeaderContentDisposition, downloadDisposition(filename)) c.Set(fiber.HeaderAcceptRanges, "bytes") rangeHeader := strings.TrimSpace(c.Get(fiber.HeaderRange)) if rangeHeader == "" { return c.SendStream(f, int(size)) } start, length, ok := parseByteRange(rangeHeader, size) if !ok { f.Close() c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes */%d", size)) return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable) } if _, err := f.Seek(start, io.SeekStart); err != nil { f.Close() return c.SendStatus(fiber.StatusInternalServerError) } c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, size)) c.Status(fiber.StatusPartialContent) return c.SendStream(&closingLimitedReader{r: io.LimitReader(f, length), closer: f}, int(length)) } type closingLimitedReader struct { r io.Reader closer io.Closer } func (l *closingLimitedReader) Read(p []byte) (int, error) { return l.r.Read(p) } func (l *closingLimitedReader) Close() error { return l.closer.Close() } // parseByteRange parses a single "bytes=start-end" range against a known size. func parseByteRange(header string, size int64) (int64, int64, bool) { if size <= 0 || !strings.HasPrefix(header, "bytes=") { return 0, 0, false } spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes=")) if strings.Contains(spec, ",") { return 0, 0, false } startStr, endStr, ok := strings.Cut(spec, "-") if !ok { return 0, 0, false } startStr, endStr = strings.TrimSpace(startStr), strings.TrimSpace(endStr) var start, end int64 switch { case startStr == "": n, err := strconv.ParseInt(endStr, 10, 64) if err != nil || n <= 0 { return 0, 0, false } if n > size { n = size } start, end = size-n, size-1 case endStr == "": s, err := strconv.ParseInt(startStr, 10, 64) if err != nil || s < 0 || s >= size { return 0, 0, false } start, end = s, size-1 default: s, err1 := strconv.ParseInt(startStr, 10, 64) e, err2 := strconv.ParseInt(endStr, 10, 64) if err1 != nil || err2 != nil || s < 0 || s > e || s >= size { return 0, 0, false } if e >= size { e = size - 1 } start, end = s, e } return start, end - start + 1, true } // downloadDisposition builds an RFC 6266/5987 attachment header for a filename // that may contain non-ASCII characters. func downloadDisposition(name string) string { fallback := strings.Map(func(r rune) rune { if r < 32 || r == '"' || r == '\\' || r > 126 { return '_' } return r }, name) fallback = strings.TrimSpace(fallback) if fallback == "" { fallback = "download" } var encoded strings.Builder for i := 0; i < len(name); i++ { ch := name[i] if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '!' || ch == '#' || ch == '$' || ch == '&' || ch == '+' || ch == '-' || ch == '.' || ch == '^' || ch == '_' || ch == '`' || ch == '|' || ch == '~' { encoded.WriteByte(ch) } else { fmt.Fprintf(&encoded, "%%%02X", ch) } } return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", fallback, encoded.String()) } // RepoDeleteReleaseAssetSubmit removes a single uploaded asset. // POST /:owner/:repo/releases/:id/assets/:assetID/delete func (h *Handler) RepoDeleteReleaseAssetSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } backTo := fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name) relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64) rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc") } backTo += "#" + rel.TagName if next := strings.TrimSpace(c.FormValue("next")); strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") { backTo = next } asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID) if err != nil || asset.ReleaseID != relID { h.setFlashT(c, "flash.release_asset_delete_failed", "not found") return c.Redirect(backTo, fiber.StatusSeeOther) } if err := h.repoSvc.DeleteReleaseAsset(c.UserContext(), r, asset); err != nil { h.setFlashT(c, "flash.release_asset_delete_failed", err.Error()) } else { h.setFlashT(c, "flash.release_asset_deleted") } return c.Redirect(backTo, fiber.StatusSeeOther) } func (h *Handler) RepoDeleteReleaseSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } if !r.EnableReleases { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") } relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc") } h.repoSvc.RemoveReleaseAssets(r, rel.ID) if err := h.repoSvc.DeleteRelease(c.UserContext(), rel.ID); err != nil { h.setFlashT(c, "flash.release_delete_failed", err.Error()) } else { h.setFlashT(c, "flash.release_deleted") } return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther) }