package main import ( "context" "encoding/xml" "fmt" "html/template" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "time" ) func (s *Server) star(w http.ResponseWriter, r *http.Request, repo Repo) { u := s.require(w, r) if u == nil { return } s.store.Star(u.ID, repo.ID, !s.store.Starred(u.ID, repo.ID)) http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) } func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } name := strings.TrimSpace(r.FormValue("name")) back := "/" + repo.Path() + "/settings" if name == repo.Name { http.Redirect(w, r, back, http.StatusSeeOther) return } if err := s.store.RenameRepo(repo.ID, name); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } renamed := repo renamed.Name = name if err := os.Rename(s.repoDir(repo), s.repoDir(renamed)); err != nil { s.store.RenameRepo(repo.ID, repo.Name) http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/"+renamed.Path()+"/settings", http.StatusSeeOther) } func (s *Server) transferRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } back := "/" + repo.Path() + "/settings" target, err := s.store.UserByName(strings.TrimSpace(strings.TrimPrefix(r.FormValue("owner"), "~"))) if err != nil { http.Redirect(w, r, back+"?error=No+such+user", http.StatusSeeOther) return } if target.ID == repo.Owner.ID { http.Redirect(w, r, back, http.StatusSeeOther) return } if r.FormValue("confirm") != repo.Name { http.Redirect(w, r, back+"?error=Type+the+repository+name+to+confirm+the+transfer", http.StatusSeeOther) return } if err := s.store.TransferRepo(repo.ID, target.ID); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } moved := repo moved.Owner = target os.MkdirAll(filepath.Dir(s.repoDir(moved)), 0o755) if err := os.Rename(s.repoDir(repo), s.repoDir(moved)); err != nil { s.store.TransferRepo(repo.ID, repo.Owner.ID) http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } s.notify(s.store.VerifiedEmail(target.ID), fmt.Sprintf("[%s] repository transferred to you", moved.Path()), fmt.Sprintf("~%s transferred %s to you. It now lives at https://%s/%s\n", repo.Owner.Name, repo.Name, s.host, moved.Path())) http.Redirect(w, r, "/"+moved.Path(), http.StatusSeeOther) } func (s *Server) archiveRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } s.store.SetArchived(repo.ID, !repo.Archived) http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther) } type BlameLine struct { Hash string Author string Time time.Time Line int Text string First bool Subject string } func (g Git) Blame(commit, filePath string) ([]BlameLine, error) { out, err := g.run(nil, "blame", "--porcelain", commit, "--", filePath) if err != nil { return nil, err } meta := map[string]*BlameLine{} var lines []BlameLine var cur *BlameLine for _, line := range strings.Split(string(out), "\n") { switch { case strings.HasPrefix(line, "\t"): if cur != nil { cur.Text = line[1:] lines = append(lines, *cur) cur = nil } case cur == nil: f := strings.Fields(line) if len(f) < 3 || len(f[0]) != 40 { continue } n, _ := strconv.Atoi(f[2]) base := meta[f[0]] if base == nil { base = &BlameLine{Hash: f[0]} meta[f[0]] = base } cur = &BlameLine{Hash: f[0], Author: base.Author, Time: base.Time, Subject: base.Subject, Line: n} case strings.HasPrefix(line, "author "): meta[cur.Hash].Author = strings.TrimPrefix(line, "author ") cur.Author = meta[cur.Hash].Author case strings.HasPrefix(line, "author-time "): t, _ := strconv.ParseInt(strings.TrimPrefix(line, "author-time "), 10, 64) meta[cur.Hash].Time = time.Unix(t, 0) cur.Time = meta[cur.Hash].Time case strings.HasPrefix(line, "summary "): meta[cur.Hash].Subject = strings.TrimPrefix(line, "summary ") cur.Subject = meta[cur.Hash].Subject } } prev := "" for i := range lines { lines[i].First = lines[i].Hash != prev prev = lines[i].Hash } return lines, nil } func (s *Server) blame(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := s.splitRef(g, spec) if !ok { s.fail(w, r, 404, "No such ref") return } filePath, ok = cleanPath(filePath) if !ok || filePath == "" { s.fail(w, r, 404, "No such path") return } rp, g, err := s.repoPage(r, repo, ref) if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "tree" if g.Size(rp.Commit, filePath) > maxRender { s.fail(w, r, 413, "File too large to blame") return } lines, err := g.Blame(rp.Commit, filePath) if err != nil { s.fail(w, r, 404, "No such path") return } crumbs := strings.Split(filePath, "/") data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs, "Lines": lines, "File": true, "Blame": true} if entry := g.Entry(rp.Commit, filePath); entry != nil { data["Entry"] = entry } if last, _ := g.Log(rp.Commit, 1, filePath); len(last) > 0 { data["LastCommit"] = last[0] } s.render(w, r, "blame", page{Title: repo.Path() + "/" + filePath + " blame", Data: data}) } func (s *Server) compare(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { rp, g, err := s.repoPage(r, repo, "") if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "log" branches, _ := g.Refs("refs/heads") data := map[string]any{"Page": rp, "Branches": branches} base, head, ok := strings.Cut(spec, "...") if !ok { base, head = r.URL.Query().Get("base"), r.URL.Query().Get("head") } data["Base"], data["Head"] = base, head if base != "" && head != "" { bh, err1 := g.Resolve(base) hh, err2 := g.Resolve(head) if err1 != nil || err2 != nil { s.fail(w, r, 404, "No such ref") return } if mb, err := g.MergeBase(bh, hh); err == nil { commits, _ := g.LogRange(mb, hh, 200) files, _ := g.DiffRange(mb, hh) added, removed := 0, 0 for _, f := range files { added += f.Added removed += f.Removed } data["Commits"], data["Files"], data["Added"], data["Removed"], data["HeadHash"] = commits, files, added, removed, hh } else { data["Unrelated"] = true } } s.render(w, r, "compare", page{Title: repo.Path() + " compare", Data: data}) } func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { g := Git{Dir: s.repoDir(repo)} format := "" switch { case strings.HasSuffix(spec, ".tar.gz"): format, spec = "tar.gz", strings.TrimSuffix(spec, ".tar.gz") case strings.HasSuffix(spec, ".zip"): format, spec = "zip", strings.TrimSuffix(spec, ".zip") default: s.fail(w, r, 404, "Use .tar.gz or .zip") return } commit, err := g.Resolve(spec) if err != nil { s.fail(w, r, 404, "No such ref") return } safe := strings.NewReplacer("/", "-", "\\", "-").Replace(spec) name := repo.Name + "-" + safe ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "archive", "--format="+format, "--prefix="+name+"/", commit) cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", `attachment; filename="`+name+"."+format+`"`) cmd.Stdout = w cmd.Run() } type atomFeed struct { XMLName xml.Name `xml:"feed"` NS string `xml:"xmlns,attr"` Title string `xml:"title"` ID string `xml:"id"` Updated string `xml:"updated"` Link atomLink `xml:"link"` Entries []atomEntry `xml:"entry"` } type atomLink struct { Href string `xml:"href,attr"` } type atomEntry struct { Title string `xml:"title"` ID string `xml:"id"` Updated string `xml:"updated"` Author string `xml:"author>name"` Link atomLink `xml:"link"` Content string `xml:"content"` } func (s *Server) feed(w http.ResponseWriter, r *http.Request, repo Repo) { rp, g, err := s.repoPage(r, repo, r.URL.Query().Get("ref")) if err != nil { s.fail(w, r, 404, "No such ref") return } base := "https://" + s.host + "/" + repo.Path() feed := atomFeed{NS: "http://www.w3.org/2005/Atom", Title: repo.Path() + " commits on " + rp.Ref, ID: base + "/feed", Link: atomLink{Href: base}, Updated: time.Now().UTC().Format(time.RFC3339)} if !rp.Empty { commits, _ := g.Log(rp.Commit, 30, "") for _, c := range commits { feed.Entries = append(feed.Entries, atomEntry{Title: c.Subject, ID: base + "/commit/" + c.Hash, Updated: c.Time.UTC().Format(time.RFC3339), Author: c.Author, Link: atomLink{Href: base + "/commit/" + c.Hash}, Content: c.Body}) } if len(commits) > 0 { feed.Updated = commits[0].Time.UTC().Format(time.RFC3339) } } w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") w.Write([]byte(xml.Header)) xml.NewEncoder(w).Encode(feed) }