package forge import ( "errors" "fmt" "log" "net/http" "net/url" "os" "path" "path/filepath" "slices" "strings" ) type problem struct { code int msg string } func (p *problem) Error() string { return p.msg } func refuse(code int, msg string) error { return &problem{code, msg} } func short(h string) string { if len(h) > 10 { return h[:10] } return h } func problemCode(err error) int { var p *problem if errors.As(err, &p) { return p.code } return 400 } func (s *Server) settle(w http.ResponseWriter, r *http.Request, back string, err error) { switch { case err == nil: http.Redirect(w, r, back, http.StatusSeeOther) case problemCode(err) == 400 || problemCode(err) == 409: target, frag, _ := strings.Cut(back, "#") if frag != "" { frag = "#" + frag } http.Redirect(w, r, target+"?error="+url.QueryEscape(err.Error())+frag, http.StatusSeeOther) default: s.fail(w, r, problemCode(err), err.Error()) } } func (s *Server) materialise(repo Repo, build func(dir string) error) error { dir := s.repoDir(repo) err := os.MkdirAll(filepath.Dir(dir), 0o755) if err == nil { err = build(dir) } if err != nil { log.Printf("create %s: %v", repo.Path(), err) os.RemoveAll(dir) s.store.DeleteRepo(repo.ID) return refuse(500, "Could not create the repository") } installHook(s.root, s.host, dir) return nil } func (s *Server) repoRoom(u User, owner User) error { if s.store.IsAdmin(u.ID) || s.store.RepoCount(owner.ID) < maxRepos { return nil } return refuse(403, fmt.Sprintf("~%s already has %d repositories, the most an account may hold, delete one first", owner.Name, maxRepos)) } func (s *Server) createRepo(u User, ownerName, name, description string, vis int, tags string) (Repo, error) { owner := u if ownerName != "" && ownerName != u.Name { org, err := s.store.UserByName(ownerName) if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) { return Repo{}, refuse(403, "You are not a member of that organization") } owner = org } if err := s.repoRoom(u, owner); err != nil { return Repo{}, err } if s.uploads.blocked("create:" + u.Name) { return Repo{}, refuse(429, "Too many new repositories, try again later") } s.uploads.fail("create:" + u.Name) repo, err := s.store.CreateRepo(owner, strings.TrimSpace(name), strings.TrimSpace(description), vis, tags) if err != nil { return Repo{}, err } return repo, s.materialise(repo, initRepo) } func (s *Server) forkRepo(u User, repo Repo) (Repo, error) { if u.ID == repo.Owner.ID { return Repo{}, refuse(409, "You cannot fork your own repository") } if s.shunned(repo, &u) { return Repo{}, refuse(403, "You cannot fork this repository") } if err := s.repoRoom(u, u); err != nil { return Repo{}, err } if s.uploads.blocked("create:" + u.Name) { return Repo{}, refuse(429, "Too many new repositories, try again later") } s.uploads.fail("create:" + u.Name) fork, err := s.store.ForkRepo(u, repo) if err != nil { return Repo{}, err } return fork, s.materialise(fork, func(dir string) error { return cloneRepo(s.repoDir(repo), dir) }) } func (s *Server) ownRepo(u *User, repo Repo) error { if !s.store.Owns(repo, u) { return refuse(403, "Not your repository") } return nil } func (s *Server) renameRepoTo(u User, repo Repo, name string) (Repo, error) { if err := s.ownRepo(&u, repo); err != nil { return repo, err } if name == repo.Name { return repo, nil } if err := s.store.RenameRepo(repo.ID, name); err != nil { return repo, err } renamed := repo renamed.Name = name if err := os.Rename(s.repoDir(repo), s.repoDir(renamed)); err != nil { s.store.RenameRepo(repo.ID, repo.Name) return repo, refuse(500, err.Error()) } s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID) return renamed, nil } func (s *Server) transferRepoTo(u User, repo Repo, owner string) (Repo, error) { if err := s.ownRepo(&u, repo); err != nil { return repo, err } target, err := s.store.UserByName(strings.TrimSpace(strings.TrimPrefix(owner, "~"))) if err != nil { return repo, refuse(404, "No such user") } if target.ID == repo.Owner.ID { return repo, nil } if err := s.repoRoom(u, target); err != nil { return repo, err } if err := s.store.TransferRepo(repo.ID, target.ID); err != nil { return repo, err } 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) return repo, refuse(500, err.Error()) } s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID) 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())) return moved, nil } func (s *Server) removeRepo(u User, repo Repo) error { if err := s.ownRepo(&u, repo); err != nil { return err } if err := s.store.DeleteRepo(repo.ID); err != nil { return refuse(500, err.Error()) } os.RemoveAll(s.repoDir(repo)) return nil } func (s *Server) starRepo(u User, repo Repo, on bool) error { if on && s.shunned(repo, &u) { return refuse(403, "You cannot star this repository") } s.store.Star(u.ID, repo.ID, on) return nil } func (s *Server) followUser(u User, owner User, on bool) error { if u.ID == owner.ID { return refuse(409, "You cannot follow yourself") } if on && s.store.Blocked(owner.ID, u.ID) { return refuse(403, "You cannot follow this account") } s.store.Follow(u.ID, owner.ID, on) return nil } func (s *Server) setMember(u User, org User, name string, on bool) error { if !s.store.IsMember(org.ID, u.ID) { return refuse(403, "You are not a member of this organization") } target, err := s.store.UserByName(strings.TrimPrefix(strings.TrimSpace(name), "~")) if err != nil || s.store.IsOrg(target.ID) { return refuse(404, "No such user") } if !on && len(s.store.Members(org.ID)) == 1 && s.store.IsMember(org.ID, target.ID) { return refuse(409, "An organization needs at least one member") } return s.store.SetMember(org.ID, target.ID, on) } func (s *Server) createOrg(u User, name string) (User, error) { if s.limiter.blocked("org:" + u.Name) { return User{}, refuse(429, "Too many organizations created recently, try again later") } org, err := s.store.CreateOrg(strings.TrimSpace(name)) if err == nil { err = s.store.SetMember(org.ID, u.ID, true) } if err != nil { return User{}, err } s.limiter.fail("org:" + u.Name) return org, nil } func (s *Server) openIssue(u User, repo Repo, title, body string) (Issue, error) { if repo.Archived { return Issue{}, refuse(403, "This repository is archived") } if s.shunned(repo, &u) { return Issue{}, refuse(403, "You cannot open issues here") } if !s.store.ThreadBudget(u.ID) { return Issue{}, refuse(429, "Too many new issues and pull requests, try again later") } issue, err := s.store.CreateIssue(repo, u, title, strings.TrimSpace(body)) if err != nil { return Issue{}, err } s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), u, repo, "issue", issue.Number, issue.Title, "opened") if repo.Owner.ID != u.ID { s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title), fmt.Sprintf("~%s opened an issue on %s.\n\n%s\n\nhttps://%s/%s/issues/%d\n", u.Name, repo.Path(), issue.Body, s.host, repo.Path(), issue.Number)) } return issue, nil } func (s *Server) commentIssue(u User, repo Repo, issue Issue, body string) error { if repo.Archived { return refuse(403, "This repository is archived") } if s.shunned(repo, &u) { return refuse(403, "You cannot comment here") } if err := s.store.AddIssueComment(issue.ID, u, body); err != nil { return err } s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), u, repo, "issue", issue.Number, issue.Title, "commented on") subject := fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title) link := fmt.Sprintf("https://%s/%s/issues/%d", s.host, repo.Path(), issue.Number) for _, id := range []int64{issue.Author.ID, repo.Owner.ID} { if id != u.ID { s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", u.Name, issue.Number, body, link)) } } return nil } func (s *Server) setIssueState(u User, repo Repo, issue Issue, open bool) error { if !s.store.CanWrite(repo, &u) && u.ID != issue.Author.ID { return refuse(403, "Only collaborators and the author can close or reopen this issue") } state, verb := "closed", "closed" if open { state, verb = "open", "reopened" } if issue.State == state { return nil } s.store.SetIssueState(issue.ID, state) s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), u, repo, "issue", issue.Number, issue.Title, verb) return nil } func (s *Server) setLabels(u User, repo Repo, kind string, item int64, ids []int64) error { if !s.store.CanWrite(repo, &u) { return refuse(403, "Only collaborators can change labels and assignees") } s.store.SetItemLabels(kind, item, repo.ID, ids) return nil } func (s *Server) setAssignees(u User, repo Repo, kind string, item int64, ids []int64) error { if !s.store.CanWrite(repo, &u) { return refuse(403, "Only collaborators can change labels and assignees") } allowed := map[int64]bool{} for _, a := range s.store.Assignable(repo) { allowed[a.ID] = true } var keep []int64 for _, id := range ids { if allowed[id] { keep = append(keep, id) } } s.store.SetAssignees(kind, item, keep) return nil } func (s *Server) openPull(u User, repo Repo, source, target, title, body string, draft bool) (Pull, error) { g := Git{Dir: s.repoDir(repo)} if repo.Archived { return Pull{}, refuse(403, "This repository is archived") } if s.store.IsPullMirror(repo.ID) { return Pull{}, refuse(403, "This repository is a mirror and does not take pull requests") } if s.shunned(repo, &u) { return Pull{}, refuse(403, "You cannot open pull requests here") } if !s.store.ThreadBudget(u.ID) { return Pull{}, refuse(429, "Too many new issues and pull requests, try again later") } src, branch, err := s.resolveSource(repo, &u, source) if err != nil { return Pull{}, err } if _, err := g.Resolve("refs/heads/" + target); err != nil || !validBranch(target) { return Pull{}, refuse(400, "No such target branch") } if src.ID == repo.ID && branch == target { return Pull{}, refuse(400, "Source and target are the same branch") } pull, err := s.store.CreatePull(repo, u, title, strings.TrimSpace(body), src, branch, target) if err != nil { return Pull{}, err } if draft { s.store.SetDraft(pull.ID, true) pull.Draft = true } if err := g.Fetch(s.repoDir(src), "refs/heads/"+branch, pull.Ref()); err != nil { return Pull{}, refuse(500, "Could not fetch the source branch: "+err.Error()) } g.RecordRevision(pull) s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), u, repo, "pull", pull.Number, pull.Title, "opened") if repo.Owner.ID != u.ID { s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title), fmt.Sprintf("~%s opened a pull request on %s.\n\n%s\n\nhttps://%s/%s/pulls/%d\n", u.Name, repo.Path(), pull.Body, s.host, repo.Path(), pull.Number)) } return pull, nil } func (s *Server) pullGuard(u User, pull Pull, closing bool) error { if pull.Repo.Archived { return refuse(403, "This repository is archived") } if !closing && s.shunned(pull.Repo, &u) { return refuse(403, "You cannot take part here") } return nil } func (s *Server) pullManager(u User, pull Pull) bool { return s.store.CanWrite(pull.Repo, &u) || u.ID == pull.Author.ID } func (s *Server) commentPull(u User, pull Pull, body string) error { if err := s.pullGuard(u, pull, false); err != nil { return err } if err := s.store.AddComment(pull.ID, u, body); err != nil { return err } s.notifyPull(pull, u, "commented on", body) return nil } func (s *Server) setPullState(u User, pull Pull, open bool) error { if err := s.pullGuard(u, pull, true); err != nil { return err } if !s.pullManager(u, pull) { return refuse(403, "Only collaborators and the author can close or reopen this pull request") } from, to, verb := "open", "closed", "closed" if open { from, to, verb = "closed", "open", "reopened" } if pull.State != from { return refuse(409, "This pull request is "+pull.State) } s.store.SetPullState(pull.ID, to, "") s.store.Notify(s.store.Watchers(pull.Repo, "pull", pull.ID, pull.Author.ID), u, pull.Repo, "pull", pull.Number, pull.Title, verb) return nil } func (s *Server) setPullDraft(u User, pull Pull, draft bool) error { if err := s.pullGuard(u, pull, false); err != nil { return err } if !s.pullManager(u, pull) { return refuse(403, "Only collaborators and the author can change the draft state") } s.store.SetDraft(pull.ID, draft) return nil } func (s *Server) notePull(u User, pull Pull, filePath, side string, line int, body string) error { if err := s.pullGuard(u, pull, false); err != nil { return err } if err := s.store.AddReview(pull.ID, u, filePath, side, line, body); err != nil { return err } s.notifyPull(pull, u, fmt.Sprintf("reviewed %s line %d on", filePath, line), body) return nil } func (s *Server) touchNote(u User, pull Pull, id int64, action string) error { if err := s.pullGuard(u, pull, false); err != nil { return err } notes, _ := s.store.Reviews(pull.ID) i := slices.IndexFunc(notes, func(r Review) bool { return r.ID == id }) if i < 0 { return refuse(404, "No such comment on this pull request") } author := notes[i].Author.ID if author != u.ID && !s.store.CanWrite(pull.Repo, &u) { return refuse(403, "Not your comment") } switch action { case "resolve": s.store.ResolveReview(id, true) case "unresolve": s.store.ResolveReview(id, false) case "delete": s.store.DeleteReview(id) } return nil } func (s *Server) judgePull(u User, pull Pull, state, body string) error { if err := s.pullGuard(u, pull, false); err != nil { return err } if u.ID == pull.Author.ID && state != "comment" { return refuse(403, "You cannot approve your own pull request") } head, _ := Git{Dir: s.repoDir(pull.Repo)}.Resolve(pull.Ref()) if err := s.store.AddPRReview(pull.ID, u, state, body, head); err != nil { return err } verb := map[string]string{"approved": "approved", "changes": "requested changes on", "comment": "reviewed"}[state] s.notifyPull(pull, u, verb, body) return nil } func (s *Server) refreshPull(u User, pull Pull) error { if err := s.pullGuard(u, pull, false); err != nil { return err } if !s.pullManager(u, pull) { return refuse(403, "Only collaborators and the author can update this pull request") } if pull.State != "open" || pull.Agit != "" { return refuse(409, "Only open pull requests from a branch can be updated") } g := Git{Dir: s.repoDir(pull.Repo)} if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil { return err } g.RecordRevision(pull) s.store.TouchPull(pull.ID) return nil } func (s *Server) mergePull(u User, pull Pull, strategy, title, body string) (string, error) { if err := s.pullGuard(u, pull, false); err != nil { return "", err } repo := pull.Repo if s.store.IsPullMirror(repo.ID) { return "", refuse(403, "This repository is a mirror and read only") } if !s.store.CanWrite(repo, &u) { return "", refuse(403, "Only people who can push here can merge") } if pull.State != "open" { return "", refuse(409, "This pull request is "+pull.State) } if pull.Draft { return "", refuse(409, "Draft pull requests cannot be merged") } g := Git{Dir: s.repoDir(repo)} if prot, ok := s.store.Protection(repo.ID, pull.TargetBranch); ok && prot.Approvals > 0 { head, _ := g.Resolve(pull.Ref()) approvals, changes := latestVerdicts(s.store.PRReviews(pull.ID), head) if approvals < prot.Approvals || changes > 0 { return "", refuse(409, fmt.Sprintf("%s needs %d approving reviews of the current head and no outstanding change requests", pull.TargetBranch, prot.Approvals)) } } title = strings.TrimSpace(title) if title == "" { title = mergeTitle(pull) } message := title if body = strings.TrimSpace(body); body != "" { message += "\n\n" + body } var hash string var err error switch strategy { case "squash": hash, err = g.Squash(pull.TargetBranch, pull.Ref(), message, pull.Author.Name, pull.Author.Name+"@"+s.host, u.Name, u.Name+"@"+s.host) case "rebase": hash, err = g.Rebase(pull.TargetBranch, pull.Ref(), u.Name, u.Name+"@"+s.host) case "ff": hash, err = g.FastForward(pull.TargetBranch, pull.Ref(), pull.baseRef()) case "", "merge": hash, err = g.Merge(pull.TargetBranch, pull.Ref(), message, u.Name, u.Name+"@"+s.host) default: return "", refuse(400, "Strategy must be merge, squash, rebase or ff") } if err != nil { return "", refuse(409, err.Error()) } s.store.SetPullState(pull.ID, "merged", hash) s.notifyPull(pull, u, "merged", "") return hash, nil } func (s *Server) editable(u *User, repo Repo, branch string) (Git, string, error) { g := Git{Dir: s.repoDir(repo)} if !s.store.CanWrite(repo, u) { return g, "", refuse(403, "You cannot write to this repository") } if repo.Archived { return g, "", refuse(403, "This repository is archived") } if s.store.IsPullMirror(repo.ID) { return g, "", refuse(403, "This repository is a mirror and read only") } head, err := g.Resolve("refs/heads/" + branch) if err != nil || !validBranch(branch) { return g, "", refuse(404, "Edits need a branch, not a tag or commit") } if prot, ok := s.store.Protection(repo.ID, branch); ok && prot.RequirePR { return g, "", refuse(403, branch+" only accepts changes through pull requests") } return g, head, nil } func (s *Server) writeFile(u User, repo Repo, branch, filePath string, content []byte, remove bool, message, parent string) (string, error) { g, head, err := s.editable(&u, repo, branch) if err != nil { return "", err } if parent != "" && parent != head { return "", refuse(409, branch+" has moved on since "+short(parent)) } kind := g.Kind(head, filePath) if remove && kind != "blob" { return "", refuse(404, "No such file") } if kind == "tree" { return "", refuse(409, filePath+" is a directory") } if strings.TrimSpace(message) == "" { verb := "Update " switch { case remove: verb = "Delete " case kind == "": verb = "Create " } message = verb + path.Base(filePath) } return g.Commit1(branch, head, filePath, content, remove, strings.TrimSpace(message), u.Name, u.Name+"@"+s.host) }