josie / simplegit

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
}