josie / simplegit

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