package main import ( "encoding/json" "fmt" "html/template" "log" "net/http" "path/filepath" "sort" "strconv" "strings" "time" ) // Issues live in {repo}.issues.git, where the server is the only writer. These // are paths inside the git tree, not files on disk: // // issues/.next next sequential id // issues/{id}.json one issue // issues/{id}/{nnnn}.json one comment, zero padded so they sort type Issue struct { ID int `json:"id"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` Author string `json:"author"` Labels []string `json:"labels"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } type Comment struct { Author string `json:"author"` Body string `json:"body"` Created time.Time `json:"created"` } const ( issueOpen = "open" issueClosed = "closed" maxTitle = 200 maxBody = 64 << 10 ) func (f *Forge) issuesDir(path string) string { return filepath.Join(f.store.reposDir, path+".issues.git") } // handleIssues routes everything under {repo}/issues. func (f *Forge) handleIssues(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) { if repo.Issues == featDisabled { f.fail(w, r, http.StatusNotFound, "this repository has issues turned off") return } // rest is issues, issues/new, issues/{id}, or issues/{id}/{action}. parts := strings.Split(rest, "/") switch { case len(parts) == 1 && isGet(r): f.issueList(w, r, path, repo) case len(parts) == 1 && r.Method == http.MethodPost: if f.checkPost(w, r) { f.issueCreate(w, r, path, repo) } case len(parts) == 2 && parts[1] == "new" && isGet(r): f.issueForm(w, r, path, repo) case len(parts) == 2 && isGet(r): f.issueShow(w, r, path, repo, parts[1]) case len(parts) == 3 && r.Method == http.MethodPost: if f.checkPost(w, r) { f.issueAction(w, r, path, repo, parts[1], parts[2]) } default: f.fail(w, r, http.StatusNotFound, "no such page") } } type issueListPage struct { Path string Issues []Issue Query string State string Labels []string Can bool // whether the viewer may open one } type issuePage struct { Path string Issue Issue Rendered template.HTML Comments []renderedComment Labels []labelState Can bool // may comment Triage bool // may close, reopen, and label } // labelState is a repository's labels with this issue's ticked. type labelState struct { Name string On bool } func labelStates(repo Repo, issue Issue) []labelState { on := map[string]bool{} for _, label := range issue.Labels { on[label] = true } out := make([]labelState, 0, len(repo.Labels)) for _, label := range repo.Labels { out = append(out, labelState{Name: label, On: on[label]}) } return out } type renderedComment struct { Comment Comment Rendered template.HTML } // issueList is the index, filtered by state and by git grep. func (f *Forge) issueList(w http.ResponseWriter, r *http.Request, path string, repo Repo) { dir := f.issuesDir(path) state := r.URL.Query().Get("state") if state != issueClosed { state = issueOpen } query := strings.TrimSpace(r.URL.Query().Get("q")) // The query becomes an argument to git grep, which cannot hold a NUL. if hasControlByte(query) { f.fail(w, r, http.StatusBadRequest, "that search has a control byte in it") return } issues, err := f.readIssues(dir, query) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return } shown := []Issue{} for _, issue := range issues { if issue.State == state { shown = append(shown, issue) } } sort.Slice(shown, func(i, j int) bool { return shown[i].ID > shown[j].ID }) d := f.page(r) d.Repo = f.repoBar(r, path, "", "issues", repo) d.Data = issueListPage{ Path: path, Issues: shown, Query: query, State: state, Labels: repo.Labels, Can: f.viewer(r).CanIssues(repo), } f.render(w, "issues.html", http.StatusOK, d) } // readIssues loads every issue, or those a search matched; a hit in a comment // counts for its issue. func (f *Forge) readIssues(dir, query string) ([]Issue, error) { head, err := resolveRef(dir, branch) if err != nil || head == "" { return nil, err } var want []string if query == "" { paths, err := listTree(dir, head, "issues/") if err != nil { return nil, err } for _, p := range paths { if strings.HasSuffix(p, ".json") { want = append(want, head+":"+p) } } } else { hits, err := grepTree(dir, head, query, "issues/") if err != nil { return nil, err } seen := map[string]bool{} for _, p := range hits { id, ok := issueIDFromPath(p) if !ok || seen[id] { continue } seen[id] = true want = append(want, head+":issues/"+id+".json") } } blobs, err := catFiles(dir, want) if err != nil { return nil, err } issues := []Issue{} for i, blob := range blobs { var issue Issue if blob == nil || json.Unmarshal(blob, &issue) != nil { // Only a hand edit or a bad restore puts one here. log.Printf("issues: %q does not read as an issue, leaving it out", want[i]) continue } issues = append(issues, issue) } return issues, nil } // issueIDFromPath maps issues/7.json and issues/7/0003.json alike to 7. func issueIDFromPath(p string) (string, bool) { rest, ok := strings.CutPrefix(p, "issues/") if !ok { return "", false } id, _, _ := strings.Cut(rest, "/") id = strings.TrimSuffix(id, ".json") if _, err := strconv.Atoi(id); err != nil { return "", false } return id, true } func (f *Forge) issueForm(w http.ResponseWriter, r *http.Request, path string, repo Repo) { if !f.viewer(r).CanIssues(repo) { f.fail(w, r, http.StatusForbidden, "you may not open an issue here") return } d := f.page(r) d.Repo = f.repoBar(r, path, "", "issues", repo) d.Data = issueListPage{Path: path, Labels: repo.Labels, Can: true} f.render(w, "issue-new.html", http.StatusOK, d) } func (f *Forge) issueShow(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText string) { dir := f.issuesDir(path) head, err := resolveRef(dir, branch) if err != nil || head == "" { f.fail(w, r, http.StatusNotFound, "no such issue") return } id, err := strconv.Atoi(idText) if err != nil || id < 1 { f.fail(w, r, http.StatusNotFound, "no such issue") return } issue, err := readIssue(dir, head, id) if err != nil { f.fail(w, r, http.StatusNotFound, "no such issue") return } paths, err := listTree(dir, head, fmt.Sprintf("issues/%d/", id)) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return } sort.Strings(paths) // zero padded, so this is chronological specs := make([]string, 0, len(paths)) for _, p := range paths { specs = append(specs, head+":"+p) } blobs, err := catFiles(dir, specs) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return } comments := []renderedComment{} for i, b := range blobs { var c Comment if b == nil || json.Unmarshal(b, &c) != nil { log.Printf("issues: %q does not read as a comment, leaving it out", specs[i]) continue } comments = append(comments, renderedComment{Comment: c, Rendered: renderMarkdown(c.Body)}) } v := f.viewer(r) d := f.page(r) d.Repo = f.repoBar(r, path, "", "issues", repo) d.Data = issuePage{ Path: path, Issue: issue, Rendered: renderMarkdown(issue.Body), Comments: comments, Labels: labelStates(repo, issue), Can: v.CanIssues(repo), Triage: f.canTriage(v, repo, issue), } f.render(w, "issue.html", http.StatusOK, d) } // canTriage is who may close, reopen, and label: the author, and whoever may // push. func (f *Forge) canTriage(v Viewer, repo Repo, issue Issue) bool { return (v.SignedIn() && v.Name == issue.Author) || v.CanWrite(repo) } func (f *Forge) issueCreate(w http.ResponseWriter, r *http.Request, path string, repo Repo) { v := f.viewer(r) if !v.CanIssues(repo) { f.fail(w, r, http.StatusForbidden, "you may not open an issue here") return } if !f.issueLimit.allow(f.peer(r), v, issuesPerHour) { f.fail(w, r, http.StatusTooManyRequests, "too many issues opened in the last hour, try again later") return } title := strings.TrimSpace(r.PostFormValue("title")) body := strings.TrimSpace(r.PostFormValue("body")) if title == "" || len(title) > maxTitle || len(body) > maxBody { f.fail(w, r, http.StatusBadRequest, "an issue needs a title, and neither it nor the body may be enormous") return } // The title becomes a mail subject and a log line, both one line. if hasControlByte(title) { f.fail(w, r, http.StatusBadRequest, "a title is one line, with no control byte in it") return } now := time.Now().UTC().Truncate(time.Second) issue := Issue{ Title: title, Body: body, State: issueOpen, Author: v.Name, Labels: pickLabels(r, repo), Created: now, Updated: now, } dir := f.issuesDir(path) author, committer := f.actor(r), f.store.forgeAuthor() // Allocated inside the retry, so two at once cannot get the same number. err := commitWith(dir, author, committer, func(base string) ([]change, string, error) { next := 1 if base != "" { if data, err := catFile(dir, base+":issues/.next"); err == nil { if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { next = n } } } issue.ID = next data, err := marshalJSON(issue) if err != nil { return nil, "", err } return []change{ {path: "issues/.next", data: []byte(strconv.Itoa(next+1) + "\n")}, {path: fmt.Sprintf("issues/%d.json", next), data: data}, }, fmt.Sprintf("issues: open %d", next), nil }) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return } f.notifyIssue(path, repo, issue, v.Name, "opened") http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", path, issue.ID), http.StatusSeeOther) } // issueRef is one issue as a request resolved it; every write reads it again // inside the retry. type issueRef struct { dir string path string id int issue Issue } // Each action answers a refusal itself, and reports whether to redirect. func (f *Forge) issueAction(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText, action string) { ref := issueRef{dir: f.issuesDir(path), path: path} id, err := strconv.Atoi(idText) if err != nil || id < 1 { f.fail(w, r, http.StatusNotFound, "no such issue") return } ref.id = id head, err := resolveRef(ref.dir, branch) if err != nil || head == "" { f.fail(w, r, http.StatusNotFound, "no such issue") return } if ref.issue, err = readIssue(ref.dir, head, id); err != nil { f.fail(w, r, http.StatusNotFound, "no such issue") return } done := false switch action { case "comment": done = f.issueComment(w, r, repo, ref) case "state": done = f.issueState(w, r, repo, ref) case "labels": done = f.issueLabels(w, r, repo, ref) default: f.fail(w, r, http.StatusNotFound, "no such page") return } if done { http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", ref.path, ref.id), http.StatusSeeOther) } } func (f *Forge) issueComment(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool { v := f.viewer(r) if !v.CanIssues(repo) { f.fail(w, r, http.StatusForbidden, "you may not comment here") return false } if !f.commentLimit.allow(f.peer(r), v, commentsPerHour) { f.fail(w, r, http.StatusTooManyRequests, "too many comments in the last hour, try again later") return false } body := strings.TrimSpace(r.PostFormValue("body")) if body == "" || len(body) > maxBody { f.fail(w, r, http.StatusBadRequest, "a comment needs a body, and it may not be enormous") return false } comment := Comment{Author: v.Name, Body: body, Created: time.Now().UTC().Truncate(time.Second)} data, err := marshalJSON(comment) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return false } var updated Issue err = commitWith(ref.dir, f.actor(r), f.store.forgeAuthor(), func(base string) ([]change, string, error) { // The sequence number and timestamp come from the tree as it is now. issue, err := readIssue(ref.dir, base, ref.id) if err != nil { return nil, "", err } seq, err := nextComment(ref.dir, base, ref.id) if err != nil { return nil, "", err } issue.Updated = comment.Created meta, err := marshalJSON(issue) if err != nil { return nil, "", err } updated = issue return []change{ {path: fmt.Sprintf("issues/%d/%04d.json", ref.id, seq), data: data}, {path: issuePath(ref.id), data: meta}, }, fmt.Sprintf("issues: comment on %d", ref.id), nil }) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return false } f.notifyIssue(ref.path, repo, updated, v.Name, "commented on") return true } func (f *Forge) issueState(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool { v := f.viewer(r) if !f.canTriage(v, repo, ref.issue) { f.fail(w, r, http.StatusForbidden, "you may not close or reopen this issue") return false } // Decided against the tree, so two people pressing at once cannot both // close it. var verb string issue, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string { if i.State == issueClosed { i.State, verb = issueOpen, "reopened" return fmt.Sprintf("issues: reopen %d", ref.id) } i.State, verb = issueClosed, "closed" return fmt.Sprintf("issues: close %d", ref.id) }) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return false } f.notifyIssue(ref.path, repo, issue, v.Name, verb) return true } func (f *Forge) issueLabels(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool { if !f.canTriage(f.viewer(r), repo, ref.issue) { f.fail(w, r, http.StatusForbidden, "you may not label this issue") return false } labels := pickLabels(r, repo) _, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string { i.Labels = labels return fmt.Sprintf("issues: label %d", ref.id) }) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return false } return true } func readIssue(dir, commit string, id int) (Issue, error) { var issue Issue blob, err := catFile(dir, commit+":"+issuePath(id)) if err != nil { return issue, err } return issue, json.Unmarshal(blob, &issue) } func issuePath(id int) string { return fmt.Sprintf("issues/%d.json", id) } // updateIssue reads the issue again on every attempt, or the second writer // would commit a record built from an overtaken read. apply returns the // subject, known only after the read. func (f *Forge) updateIssue(dir string, id int, author, committer Author, apply func(*Issue) string) (Issue, error) { var final Issue err := commitWith(dir, author, committer, func(base string) ([]change, string, error) { issue, err := readIssue(dir, base, id) if err != nil { return nil, "", err } subject := apply(&issue) issue.Updated = time.Now().UTC().Truncate(time.Second) data, err := marshalJSON(issue) if err != nil { return nil, "", err } final = issue return []change{{path: issuePath(id), data: data}}, subject, nil }) return final, err } func nextComment(dir, base string, id int) (int, error) { if base == "" { return 1, nil } paths, err := listTree(dir, base, fmt.Sprintf("issues/%d/", id)) if err != nil { return 0, err } next := 1 for _, p := range paths { seq, err := strconv.Atoi(strings.TrimSuffix(filepath.Base(p), ".json")) if err == nil && seq >= next { next = seq + 1 } } return next, nil } // pickLabels keeps only labels the repository defines. func pickLabels(r *http.Request, repo Repo) []string { chosen := map[string]bool{} for _, label := range r.PostForm["label"] { chosen[label] = true } out := []string{} for _, label := range repo.Labels { if chosen[label] { out = append(out, label) } } return out }