bc57e9101744c6393d52f1a60c71c206d29347dd / internal/web/releases.go · 15528 bytes · raw
package web
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"git.josie-c.com/josie/simplegit/internal/auth"
"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"
}
// API release creation limits: the whole multipart body, each stored asset,
// and the per-release file count.
const (
releaseUploadMax = 50 << 20
maxAssetSize = 25 << 20
maxReleaseAssets = 10
maxAssetNameLen = 200
)
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])
}
// handleCreateRelease is the API path for publishing a release: the owner
// (browser session or git token over basic auth) posts a tag and optional
// title/notes plus any asset files. It mirrors the removed creation GUI —
// same limits, same rollback — so releases can be cut from the CLI with no
// SSH. Assets land in uploads/releases/<id>/ under opaque names, exactly as
// the download route expects.
//
// It is idempotent: a tag already has a release (e.g. created by the push
// hook from the tag message) is reused, and an asset re-uploaded under the
// same name replaces the stored file rather than erroring.
func (s *Server) handleCreateRelease(w http.ResponseWriter, r *http.Request) {
owner := r.PathValue("user")
repo, err := db.GetRepoByName(s.database, owner, r.PathValue("repo"))
if errors.Is(err, db.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.internalError(w, r, err)
return
}
// Authentication: a session or a git token (the same secret check pushes
// use), then ownership — the same gate as the git push path.
user := currentUser(r)
if user == nil {
username, secret, supplied := r.BasicAuth()
if !supplied {
s.gitChallenge(w)
return
}
var ok bool
user, ok = s.authenticateSecret(username, secret)
if !ok {
if !s.logins.allow(clientIP(r)) {
http.Error(w, "too many failed authentications; try again later", http.StatusTooManyRequests)
return
}
s.gitChallenge(w)
return
}
s.logins.reset(clientIP(r))
}
if !isOwner(user, repo) {
http.NotFound(w, r)
return
}
r.Body = http.MaxBytesReader(w, r.Body, releaseUploadMax)
if err := r.ParseMultipartForm(8 << 20); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "upload is too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "bad form", http.StatusBadRequest)
return
}
tag := strings.TrimSpace(r.FormValue("tag"))
title := truncate(strings.TrimSpace(r.FormValue("title")), db.ReleaseTitleMax)
notes := truncate(strings.TrimSpace(r.FormValue("notes")), db.ReleaseNotesMax)
if tag == "" {
http.Error(w, "a tag is required", http.StatusUnprocessableEntity)
return
}
repoPath := s.repoPathFor(owner, repo)
tags, err := git.Tags(repoPath)
if err != nil {
s.internalError(w, r, err)
return
}
tagOK := false
for _, t := range tags {
if t.Name == tag {
tagOK = true
break
}
}
if !tagOK {
http.Error(w, "choose a tag that exists in the repository", http.StatusUnprocessableEntity)
return
}
// Get-or-create the release: a tag push may already have created a
// notes-only release from the tag message, in which case we attach to it
// rather than failing on the UNIQUE(repo_id, tag) constraint.
release, err := db.GetReleaseByTag(s.database, repo.ID, tag)
isCreated := false
if errors.Is(err, db.ErrNotFound) {
isCreated = true
if title == "" {
title = tag
}
release, err = db.CreateRelease(s.database, repo.ID, tag, title, notes, user.ID)
if err != nil {
s.internalError(w, r, err)
return
}
} else if err != nil {
s.internalError(w, r, err)
return
}
dir := s.releaseUploadDir(release.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
s.cleanupRelease(repo, release.ID, dir, isCreated)
log.Printf("web: create release dir %s: %v", dir, err)
http.Error(w, "could not store the uploaded files", http.StatusInternalServerError)
return
}
if r.MultipartForm != nil {
if err := s.saveReleaseAssets(r, release, dir); err != nil {
s.cleanupRelease(repo, release.ID, dir, isCreated)
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"url": s.cfg.BaseURL + releaseBasePath(r) + "/" + url.PathEscape(release.Tag),
"tag": release.Tag,
"title": release.Title,
})
}
// cleanupRelease tears down a release operation that could not be finished:
// the per-release directory (always created for this operation) and, only
// when this request created it, the release row — so an existing release and
// its pre-existing assets are never removed by a failed attach.
func (s *Server) cleanupRelease(repo db.Repo, releaseID int64, dir string, isCreated bool) {
if err := os.RemoveAll(dir); err != nil {
log.Printf("web: cleanup release %d dir: %v", releaseID, err)
}
if isCreated {
if err := db.DeleteRelease(s.database, repo.ID, releaseID); err != nil {
log.Printf("web: cleanup release %d row: %v", releaseID, err)
}
}
}
// saveReleaseAssets writes each uploaded file to dir and records its row.
// Any failure leaves the whole release to be rolled back by the caller.
func (s *Server) saveReleaseAssets(r *http.Request, release db.Release, dir string) error {
files := r.MultipartForm.File["assets"]
if len(files) > maxReleaseAssets {
return fmt.Errorf("at most %d files per release", maxReleaseAssets)
}
for _, header := range files {
if err := s.saveAsset(r, release, dir, header); err != nil {
return err
}
}
return nil
}
// saveAsset stores one uploaded file under an opaque name and records it.
// An existing asset with the same display name is replaced in place: the new
// file is written first, the row updated, and only then the old file
// removed — so a failure never drops an asset the request didn't replace.
func (s *Server) saveAsset(r *http.Request, release db.Release, dir string, header *multipart.FileHeader) error {
name := safeAssetName(header.Filename)
if name == "" {
return fmt.Errorf("a file has an invalid name")
}
stored, err := auth.NewToken()
if err != nil {
return err
}
size, err := saveUploadedFile(header, filepath.Join(dir, stored))
if err != nil {
return err
}
existing, err := db.GetReleaseAssetByFilename(s.database, release.ID, name)
switch {
case err == nil:
if err := db.ReplaceReleaseAsset(s.database, release.ID, existing.ID, stored, size); err != nil {
_ = os.Remove(filepath.Join(dir, stored))
return err
}
_ = os.Remove(filepath.Join(dir, existing.StoredName))
case errors.Is(err, db.ErrNotFound):
if _, err := db.CreateReleaseAsset(s.database, release.ID, name, stored, size); err != nil {
_ = os.Remove(filepath.Join(dir, stored))
return err
}
default:
_ = os.Remove(filepath.Join(dir, stored))
return err
}
return nil
}
// saveUploadedFile streams one uploaded file to dst, capping its size, and
// returns the number of bytes written.
func saveUploadedFile(header *multipart.FileHeader, dst string) (int64, error) {
src, err := header.Open()
if err != nil {
return 0, fmt.Errorf("open upload %s: %w", header.Filename, err)
}
defer src.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return 0, fmt.Errorf("store upload %s: %w", header.Filename, err)
}
written, copyErr := io.Copy(out, io.LimitReader(src, maxAssetSize+1))
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(dst)
return 0, fmt.Errorf("store upload %s: %w", header.Filename, copyErr)
}
if closeErr != nil {
_ = os.Remove(dst)
return 0, fmt.Errorf("store upload %s: %w", header.Filename, closeErr)
}
if written > maxAssetSize {
_ = os.Remove(dst)
return 0, fmt.Errorf("file %s is larger than %d MB", header.Filename, maxAssetSize>>20)
}
return written, nil
}
// safeAssetName reduces a client-supplied filename to a single boring path
// segment; it returns "" when nothing usable remains.
func safeAssetName(raw string) string {
name := path.Base(strings.ReplaceAll(raw, "\\", "/"))
name = strings.Map(func(c rune) rune {
if c < 0x20 || c == 0x7f {
return -1
}
return c
}, name)
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." || name == "/" {
return ""
}
if len(name) > maxAssetNameLen {
name = name[:maxAssetNameLen]
}
return name
}