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// 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 }