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