b8622ddd60a831ec5e82333e8c47f4706d65253e / internal/web/releases.go · 7280 bytes · raw
package web
import (
"errors"
"fmt"
"html/template"
"log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"git.josie-c.com/josie/simplegit/internal/db"
"git.josie-c.com/josie/simplegit/internal/git"
)
func releaseBasePath(r *http.Request) string {
return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/releases"
}
func (s *Server) releaseUploadDir(releaseID int64) string {
return filepath.Join(s.cfg.DataDir, "uploads", "releases", strconv.FormatInt(releaseID, 10))
}
type releaseListRow struct {
Tag string
Title string
Created string
Href string
}
type releaseTagRow struct {
Name string
Commit string
}
type releaseListData struct {
navData
Releases []releaseListRow
Tags []releaseTagRow
}
// handleReleases lists the repo's releases and any tags without one.
// Releases are made by pushing tags with git; there is no creation UI.
func (s *Server) handleReleases(w http.ResponseWriter, r *http.Request) {
repo, repoPath, user, ok := s.resolveRepo(w, r)
if !ok {
return
}
releases, err := db.ListReleases(s.database, repo.ID)
if err != nil {
s.internalError(w, r, err)
return
}
tags, err := git.Tags(repoPath)
if err != nil {
s.internalError(w, r, err)
return
}
released := make(map[string]bool, len(releases))
data := releaseListData{navData: nav(r, repo, user, "releases")}
for _, release := range releases {
released[release.Tag] = true
data.Releases = append(data.Releases, releaseListRow{
Tag: release.Tag,
Title: release.Title,
Created: issuedAt(release.CreatedAt),
Href: releaseBasePath(r) + "/" + url.PathEscape(release.Tag),
})
}
for _, tag := range tags {
if released[tag.Name] {
continue
}
data.Tags = append(data.Tags, releaseTagRow{
Name: tag.Name,
Commit: shortSHA(tag.Commit),
})
}
s.render(w, "releases.html", http.StatusOK, data)
}
type releaseAssetView struct {
ID int64
Name string
Size string
Download string
Delete string
}
type releaseViewData struct {
navData
Tag string
Title string
Commit string
Created string
Notes template.HTML
Assets []releaseAssetView
IsOwner bool
DeleteHref string
}
// handleReleaseView shows one release with its notes and assets.
func (s *Server) handleReleaseView(w http.ResponseWriter, r *http.Request) {
repo, repoPath, user, ok := s.resolveRepo(w, r)
if !ok {
return
}
tag := r.PathValue("tag")
release, err := db.GetReleaseByTag(s.database, repo.ID, tag)
if errors.Is(err, db.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.internalError(w, r, err)
return
}
assets, err := db.ListReleaseAssets(s.database, release.ID)
if err != nil {
s.internalError(w, r, err)
return
}
data := releaseViewData{
navData: nav(r, repo, user, "releases"),
Tag: release.Tag, Title: release.Title, Created: issuedAt(release.CreatedAt),
Notes: markdownHTML(release.Notes), IsOwner: isOwner(user, repo),
}
tagPath := releaseBasePath(r) + "/" + url.PathEscape(release.Tag)
data.DeleteHref = tagPath + "/delete"
if tags, err := git.Tags(repoPath); err == nil {
for _, t := range tags {
if t.Name == release.Tag {
data.Commit = shortSHA(t.Commit)
break
}
}
}
for _, asset := range assets {
data.Assets = append(data.Assets, releaseAssetView{
ID: asset.ID,
Name: asset.Filename,
Size: humanSize(asset.Size),
Download: releaseBasePath(r) + "/download/" + strconv.FormatInt(asset.ID, 10) + "/" + url.PathEscape(asset.Filename),
Delete: tagPath + "/assets/" + strconv.FormatInt(asset.ID, 10) + "/delete",
})
}
s.render(w, "release.html", http.StatusOK, data)
}
// handleReleaseDownload serves one asset. Access follows the repo gate, so
// private repos require the owner. CSP + octet-stream + attachment headers
// keep the download origin inert even if a viewer opens the file locally.
func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
repo, _, _, ok := s.resolveRepo(w, r)
if !ok {
return
}
id, ok := pathID(r)
if !ok {
http.NotFound(w, r)
return
}
asset, err := db.GetReleaseAssetForRepo(s.database, repo.ID, id)
if errors.Is(err, db.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.internalError(w, r, err)
return
}
file, err := os.Open(filepath.Join(s.releaseUploadDir(asset.ReleaseID), asset.StoredName))
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
s.internalError(w, r, err)
return
}
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": asset.Filename})
if disposition == "" {
disposition = "attachment"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", disposition)
w.Header().Set("Content-Security-Policy", "default-src 'none'")
http.ServeContent(w, r, asset.Filename, info.ModTime(), file)
}
// handleDeleteRelease removes a release and its stored assets (owner-only).
func (s *Server) handleDeleteRelease(w http.ResponseWriter, r *http.Request) {
repo, _, _, ok := s.ownerRepo(w, r)
if !ok {
return
}
release, ok := s.releaseByTag(w, r, repo)
if !ok {
return
}
if err := db.DeleteRelease(s.database, repo.ID, release.ID); err != nil {
s.internalError(w, r, err)
return
}
if err := os.RemoveAll(s.releaseUploadDir(release.ID)); err != nil {
log.Printf("web: remove release assets: %v", err)
}
http.Redirect(w, r, releaseBasePath(r), http.StatusSeeOther)
}
// handleDeleteReleaseAsset removes one asset (owner-only).
func (s *Server) handleDeleteReleaseAsset(w http.ResponseWriter, r *http.Request) {
repo, _, _, ok := s.ownerRepo(w, r)
if !ok {
return
}
release, ok := s.releaseByTag(w, r, repo)
if !ok {
return
}
id, ok := pathID(r)
if !ok {
http.NotFound(w, r)
return
}
asset, err := db.GetReleaseAssetForRepo(s.database, repo.ID, id)
if errors.Is(err, db.ErrNotFound) || (err == nil && asset.ReleaseID != release.ID) {
http.NotFound(w, r)
return
}
if err != nil {
s.internalError(w, r, err)
return
}
if err := db.DeleteReleaseAsset(s.database, release.ID, id); err != nil {
s.internalError(w, r, err)
return
}
if err := os.Remove(filepath.Join(s.releaseUploadDir(release.ID), asset.StoredName)); err != nil {
log.Printf("web: remove release asset: %v", err)
}
http.Redirect(w, r, releaseBasePath(r)+"/"+url.PathEscape(release.Tag), http.StatusSeeOther)
}
// releaseByTag resolves the {tag} path value to a release in repo.
func (s *Server) releaseByTag(w http.ResponseWriter, r *http.Request, repo db.Repo) (db.Release, bool) {
release, err := db.GetReleaseByTag(s.database, repo.ID, r.PathValue("tag"))
if errors.Is(err, db.ErrNotFound) {
http.NotFound(w, r)
return db.Release{}, false
}
if err != nil {
s.internalError(w, r, err)
return db.Release{}, false
}
return release, true
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}