package main import ( "compress/gzip" "context" "crypto/sha256" "encoding/xml" "fmt" "html/template" "io" "net/http" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "sync" "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)} digest := strings.HasSuffix(spec, ".sha256") spec = strings.TrimSuffix(spec, ".sha256") 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 file := name + "." + format ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) defer cancel() if digest { sum := sha256.New() if err := writeArchive(ctx, sum, g.Dir, format, name, commit); err != nil { s.fail(w, r, 500, "Could not build the archive") return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "%x %s\n", sum.Sum(nil), file) return } w.Header().Set("ETag", `"`+commit+"-"+format+`"`) if commit == spec { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } if r.Header.Get("If-None-Match") == `"`+commit+"-"+format+`"` { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`) writeArchive(ctx, w, g.Dir, format, name, commit) } func writeArchive(ctx context.Context, w io.Writer, dir, format, name, commit string) error { gitFormat := format if format == "tar.gz" { gitFormat = "tar" } cmd := exec.CommandContext(ctx, "git", "-C", dir, "-c", "core.autocrlf=false", "archive", "--format="+gitFormat, "--prefix="+name+"/", commit) cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "TZ=UTC", "PATH=" + os.Getenv("PATH")} if format != "tar.gz" { cmd.Stdout = w return cmd.Run() } zw, _ := gzip.NewWriterLevel(w, gzip.BestCompression) zw.OS = 255 cmd.Stdout = zw if err := cmd.Run(); err != nil { return err } return zw.Close() } 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) } } writeFeed(w, feed) } func writeFeed(w http.ResponseWriter, feed atomFeed) { feed.NS = "http://www.w3.org/2005/Atom" if feed.Updated == "" { feed.Updated = time.Unix(0, 0).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) } func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339) } func (s *Server) issueFeed(w http.ResponseWriter, r *http.Request, repo Repo) { issues, _ := s.store.Issues(repo.ID, "") base := "https://" + s.host + "/" + repo.Path() + "/issues" feed := atomFeed{Title: repo.Path() + " issues", ID: base + "/feed", Link: atomLink{Href: base}} for i, is := range issues { if i == 50 { break } if feed.Updated == "" { feed.Updated = stamp(is.Updated) } link := fmt.Sprintf("%s/%d", base, is.Number) feed.Entries = append(feed.Entries, atomEntry{Title: fmt.Sprintf("#%d %s [%s]", is.Number, is.Title, is.State), ID: link, Updated: stamp(is.Updated), Author: "~" + is.Author.Name, Link: atomLink{Href: link}, Content: is.Body}) } writeFeed(w, feed) } func (s *Server) pullFeed(w http.ResponseWriter, r *http.Request, repo Repo) { pulls, _ := s.store.Pulls(repo, "") slices.SortStableFunc(pulls, func(a, b Pull) int { return b.Updated.Compare(a.Updated) }) base := "https://" + s.host + "/" + repo.Path() + "/pulls" feed := atomFeed{Title: repo.Path() + " pull requests", ID: base + "/feed", Link: atomLink{Href: base}} for i, p := range pulls { if i == 50 { break } if feed.Updated == "" { feed.Updated = stamp(p.Updated) } link := fmt.Sprintf("%s/%d", base, p.Number) feed.Entries = append(feed.Entries, atomEntry{Title: fmt.Sprintf("#%d %s [%s]", p.Number, p.Title, p.State), ID: link, Updated: stamp(p.Updated), Author: "~" + p.Author.Name, Link: atomLink{Href: link}, Content: p.Body}) } writeFeed(w, feed) } func (s *Server) userFeed(w http.ResponseWriter, r *http.Request, owner User) { repos, _ := s.store.ReposByOwner(owner.ID, false) type entry struct { repo Repo commit Commit } var all []entry for _, repo := range repos { if repo.Unlisted { continue } g := Git{Dir: s.repoDir(repo)} head, err := g.Resolve("HEAD") if err != nil { continue } commits, _ := g.Log(head, 20, "") for _, c := range commits { all = append(all, entry{repo, c}) } } slices.SortStableFunc(all, func(a, b entry) int { return b.commit.Time.Compare(a.commit.Time) }) base := "https://" + s.host + "/~" + owner.Name feed := atomFeed{Title: "~" + owner.Name + " commits", ID: base + "/feed", Link: atomLink{Href: base}} for i, e := range all { if i == 50 { break } if feed.Updated == "" { feed.Updated = stamp(e.commit.Time) } link := "https://" + s.host + "/" + e.repo.Path() + "/commit/" + e.commit.Hash feed.Entries = append(feed.Entries, atomEntry{Title: e.repo.Name + ": " + e.commit.Subject, ID: link, Updated: stamp(e.commit.Time), Author: e.commit.Author, Link: atomLink{Href: link}, Content: e.commit.Body}) } writeFeed(w, feed) } type activityCache struct { sync.Mutex entries map[int64]activityEntry } type activityEntry struct { at time.Time counts map[string]int } var activities = activityCache{entries: map[int64]activityEntry{}} func (s *Server) activityCounts(owner User) map[string]int { activities.Lock() if e, ok := activities.entries[owner.ID]; ok && time.Since(e.at) < 10*time.Minute { activities.Unlock() return e.counts } activities.Unlock() counts := map[string]int{} email := strings.ToLower(s.store.VerifiedEmail(owner.ID)) if email == "" { return counts } seen := map[string]bool{} repos, _ := s.store.PublicRepos("", "") for _, repo := range repos { g := Git{Dir: s.repoDir(repo)} out, err := g.run(nil, "log", "--all", "--no-merges", "--format=%H%x09%at%x09%ae") if err != nil { continue } for _, line := range strings.Split(string(out), "\n") { f := strings.Split(line, "\t") if len(f) != 3 || strings.ToLower(f[2]) != email || seen[f[0]] { continue } seen[f[0]] = true sec, _ := strconv.ParseInt(f[1], 10, 64) counts[time.Unix(sec, 0).UTC().Format("2006-01-02")]++ } } activities.Lock() activities.entries[owner.ID] = activityEntry{time.Now(), counts} activities.Unlock() return counts } type heatView struct { Grid template.HTML Total int Year int Years []int } func (s *Server) activity(owner User, year int) heatView { counts := s.activityCounts(owner) today := time.Now().UTC().Truncate(24 * time.Hour) years := map[int]bool{} for y := min(owner.Created.UTC().Year(), today.Year()); y <= today.Year(); y++ { years[y] = true } for day := range counts { y, _ := strconv.Atoi(day[:4]) years[y] = true } view := heatView{Year: year} for y := range years { view.Years = append(view.Years, y) } slices.SortFunc(view.Years, func(a, b int) int { return b - a }) first, last := today.AddDate(0, 0, -364), today if year != 0 { first = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) last = time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC) } start := first.AddDate(0, 0, -int(first.Weekday())) weeks := int(last.Sub(start).Hours()/24)/7 + 1 peak := 0 for d := first; !d.After(last); d = d.AddDate(0, 0, 1) { n := counts[d.Format("2006-01-02")] peak = max(peak, n) view.Total += n } var b strings.Builder fmt.Fprintf(&b, `