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)
}
// 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
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)
}