package web import ( "net/http" "slices" "strconv" "strings" "github.com/go-chi/chi/v5" "hearthforge/internal/db" "hearthforge/internal/util" "hearthforge/internal/web/views" ) const issuesPerPage = 20 // allowedReaction reports whether the emoji is in the picker set. func allowedReaction(emoji string) bool { return slices.Contains(views.AllowedReactions, emoji) } // visibleRepo loads the {repo} URL parameter and hides private repos from // non-admins. It writes a 404 and returns false when the repo is not visible. func (s *Server) visibleRepo(w http.ResponseWriter, r *http.Request) (*db.Repo, bool) { u := User(r) repo, err := s.DB.GetRepo(r.Context(), chi.URLParam(r, "repo"), u != nil && u.IsAdmin) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return nil, false } if repo == nil { http.Error(w, "Not found", http.StatusNotFound) return nil, false } return repo, true } // leadingInt parses the digits at the start of s, like JavaScript's parseInt. // It returns false when s does not start with a number. func leadingInt(s string) (int64, bool) { end := 0 for end < len(s) && s[end] >= '0' && s[end] <= '9' { end++ } if end == 0 { return 0, false } n, err := strconv.ParseInt(s[:end], 10, 64) return n, err == nil } // parseLabelIDs turns repeated form or query values into label ids. func parseLabelIDs(values []string) []int64 { var out []int64 for _, v := range values { if n, ok := leadingInt(v); ok { out = append(out, n) } } return out } // groupReactions counts the reactions on one target. commentID is nil for the // issue or patch body itself. userID is 0 for anonymous viewers. func groupReactions(reactions []db.Reaction, commentID *int64, userID int64) []views.ReactionCount { var out []views.ReactionCount index := map[string]int{} for _, r := range reactions { if commentID == nil { if r.CommentID != nil { continue } } else if r.CommentID == nil || *r.CommentID != *commentID { continue } i, ok := index[r.Emoji] if !ok { i = len(out) index[r.Emoji] = i out = append(out, views.ReactionCount{Emoji: r.Emoji}) } out[i].Count++ if userID != 0 && r.UserID == userID { out[i].UserReacted = true } } return out } // issueNumber reads the {number} URL parameter. func issueNumber(r *http.Request) int64 { n, _ := leadingInt(chi.URLParam(r, "number")) return n } // tooLong rejects a field that exceeds its byte cap. func tooLong(w http.ResponseWriter, value string, max int) bool { if len(value) <= max { return false } http.Error(w, "Bad Request", http.StatusUnprocessableEntity) return true } func (s *Server) issueRoutes(r chi.Router) { r.Get("/{repo}/issues", s.issueList) r.Get("/{repo}/issues/{number}", s.issueDetail) r.Group(func(r chi.Router) { r.Use(s.requireAuth) r.Get("/{repo}/issues/new", s.newIssue) r.Post("/{repo}/issues", s.createIssue) r.Post("/{repo}/issues/{number}/comments", s.addIssueComment) r.Post("/{repo}/issues/{number}/comments/{id}/edit", s.editIssueComment) r.Post("/{repo}/issues/{number}/react", s.reactIssue) r.Post("/{repo}/issues/{number}/delete", s.deleteIssue) r.Post("/{repo}/issues/{number}/edit", s.editIssue) }) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Post("/{repo}/issues/{number}/complete", s.completeIssue) r.Post("/{repo}/issues/{number}/close", s.closeIssue) }) // The label routes answer 401 instead of redirecting, so they do their // own auth check. r.Post("/{repo}/issues/{number}/labels/add", s.addIssueLabel) r.Post("/{repo}/issues/{number}/labels/remove", s.removeIssueLabel) } func (s *Server) issueList(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } q := r.URL.Query() status := "open" switch q.Get("status") { case "closed": status = "closed" case "completed": status = "completed" } labelIDs := parseLabelIDs(q["labels"]) repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } counts, err := s.DB.IssueCounts(r.Context(), repo.ID, labelIDs) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } page := util.Paginate(util.ParsePage(q.Get("page")), counts[status], issuesPerPage) issues, err := s.DB.ListIssues(r.Context(), repo.ID, status, labelIDs, issuesPerPage, page.Offset) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } ids := make([]int64, len(issues)) for i, issue := range issues { ids[i] = issue.ID } labelsByIssue, err := s.DB.IssueLabelsByIssue(r.Context(), ids) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } pageInfo := views.PageInfo{ Page: page.Page, TotalPages: page.TotalPages, URLTemplate: "/" + repo.Name + "/issues?status=" + status + views.LabelsQueryParam(labelIDs) + "&page={page}", } views.Render(w, http.StatusOK, views.IssueList(s.Cfg, User(r), repo, issues, status, counts, pageInfo, repoLabels, labelIDs, labelsByIssue)) } func (s *Server) newIssue(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } labels, err := s.DB.ListLabels(r.Context(), repo.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } template := "" if repo.IssueTemplate != nil { template = *repo.IssueTemplate } views.Render(w, http.StatusOK, views.NewIssue(s.Cfg, User(r), repo, "", template, labels)) } func (s *Server) createIssue(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, issueCreateLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } user := User(r) title := r.FormValue("title") body := r.FormValue("body") if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, body, s.Cfg.MaxTextBodyBytes) { return } if strings.TrimSpace(title) == "" { labels, err := s.DB.ListLabels(r.Context(), repo.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } views.Render(w, http.StatusOK, views.NewIssue(s.Cfg, user, repo, "Title is required", "", labels)) return } var labelIDs []int64 if user.IsAdmin || repo.AllowUserLabels { labelIDs = parseLabelIDs(r.Form["label_ids"]) } number, err := s.DB.CreateIssue(r.Context(), repo.ID, &user.ID, strings.TrimSpace(title), body, db.NowISO(), labelIDs) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(number, 10), http.StatusFound) } func (s *Server) issueDetail(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } issue, err := s.DB.IssueByNumber(r.Context(), repo.ID, issueNumber(r)) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if issue == nil { http.Error(w, "Not found", http.StatusNotFound) return } user := User(r) viewerID := int64(0) if user != nil { viewerID = user.ID } comments, err := s.DB.ListIssueComments(r.Context(), issue.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } reactionRows, err := s.DB.ListIssueReactions(r.Context(), issue.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } issueLabels, err := s.DB.IssueLabels(r.Context(), issue.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } thread := make([]views.ThreadComment, len(comments)) commentReactions := make(map[int64][]views.ReactionCount, len(comments)) for i, c := range comments { username := "" if c.AuthorUsername != nil { username = *c.AuthorUsername } thread[i] = views.ThreadComment{ ID: c.ID, AuthorID: c.AuthorID, AuthorUsername: username, AuthorAvatarVersion: c.AuthorAvatarVersion, Body: c.Body, BodyHTML: s.MD.Render(c.Body, "", nil), CreatedAt: c.CreatedAt, EditedAt: c.EditedAt, } id := c.ID commentReactions[c.ID] = groupReactions(reactionRows, &id, viewerID) } views.Render(w, http.StatusOK, views.IssueDetail(s.Cfg, user, repo, issue, s.MD.Render(issue.Body, "", nil), thread, groupReactions(reactionRows, nil, viewerID), commentReactions, issueLabels, repoLabels)) } func (s *Server) addIssueComment(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, commentLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } num := issueNumber(r) issue, ok := s.issueRef(w, r, repo.ID, num) if !ok { return } target := "/" + repo.Name + "/issues/" + strconv.FormatInt(num, 10) user := User(r) // Only an admin may comment on a closed issue. if issue.Status == "closed" && !user.IsAdmin { http.Redirect(w, r, target, http.StatusFound) return } body := r.FormValue("body") if tooLong(w, body, s.Cfg.MaxTextBodyBytes) { return } if strings.TrimSpace(body) == "" { http.Redirect(w, r, target, http.StatusFound) return } if err := s.DB.AddIssueComment(r.Context(), issue.ID, &user.ID, strings.TrimSpace(body), db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, target, http.StatusFound) } func (s *Server) editIssueComment(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, commentLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } commentID, _ := leadingInt(chi.URLParam(r, "id")) auth, err := s.DB.IssueCommentAuth(r.Context(), commentID, repo.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if auth == nil { http.Error(w, "Not found", http.StatusNotFound) return } user := User(r) if (auth.AuthorID == nil || *auth.AuthorID != user.ID) && !user.IsAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } if auth.Status != "open" && !user.IsAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } body := r.FormValue("edit_body") if tooLong(w, body, s.Cfg.MaxTextBodyBytes) { return } if strings.TrimSpace(body) == "" { http.Error(w, "Comment is required", http.StatusUnprocessableEntity) return } if err := s.DB.UpdateIssueComment(r.Context(), commentID, strings.TrimSpace(body), db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues/"+chi.URLParam(r, "number"), http.StatusFound) } func (s *Server) reactIssue(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, reactionLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } emoji := r.FormValue("emoji") if !allowedReaction(emoji) { http.Error(w, "Invalid emoji", http.StatusBadRequest) return } num := issueNumber(r) issue, ok := s.issueRef(w, r, repo.ID, num) if !ok { return } var commentID *int64 if raw := r.FormValue("comment_id"); raw != "" { if n, ok := leadingInt(raw); ok { commentID = &n } } if err := s.DB.ToggleIssueReaction(r.Context(), issue.ID, commentID, User(r).ID, emoji); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusSeeOther) } func (s *Server) completeIssue(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } num := issueNumber(r) issue, ok := s.issueRef(w, r, repo.ID, num) if !ok { return } if err := s.DB.CompleteIssue(r.Context(), issue.ID, db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound) } func (s *Server) closeIssue(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } num := issueNumber(r) issue, ok := s.issueRef(w, r, repo.ID, num) if !ok { return } if err := s.DB.ToggleIssueClosed(r.Context(), issue.ID, db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound) } func (s *Server) deleteIssue(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } issue, ok := s.issueRef(w, r, repo.ID, issueNumber(r)) if !ok { return } user := User(r) if (issue.AuthorID == nil || *issue.AuthorID != user.ID) && !user.IsAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } if err := s.DB.DeleteIssue(r.Context(), issue.ID); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues", http.StatusFound) } func (s *Server) editIssue(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, commentLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } num := issueNumber(r) issue, ok := s.issueRef(w, r, repo.ID, num) if !ok { return } user := User(r) if (issue.AuthorID == nil || *issue.AuthorID != user.ID) && !user.IsAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } if issue.Status != "open" && !user.IsAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } title := r.FormValue("title") body := r.FormValue("edit_body") if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, body, s.Cfg.MaxTextBodyBytes) { return } if strings.TrimSpace(title) == "" { http.Error(w, "Title is required", http.StatusUnprocessableEntity) return } if err := s.DB.UpdateIssue(r.Context(), issue.ID, strings.TrimSpace(title), body, db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound) } func (s *Server) addIssueLabel(w http.ResponseWriter, r *http.Request) { repo, issue, num, ok := s.issueLabelTarget(w, r) if !ok { return } labelID, _ := leadingInt(r.FormValue("label_id")) target := "/" + repo.Name + "/issues/" + strconv.FormatInt(num, 10) label, err := s.DB.LabelInRepo(r.Context(), labelID, repo.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if label == nil { http.Redirect(w, r, target, http.StatusFound) return } if err := s.DB.AddIssueLabel(r.Context(), issue.ID, label.ID); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, target, http.StatusFound) } func (s *Server) removeIssueLabel(w http.ResponseWriter, r *http.Request) { repo, issue, num, ok := s.issueLabelTarget(w, r) if !ok { return } labelID, _ := leadingInt(r.FormValue("label_id")) if err := s.DB.RemoveIssueLabel(r.Context(), issue.ID, labelID); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound) } // issueLabelTarget runs the shared checks of the label add and remove routes. func (s *Server) issueLabelTarget(w http.ResponseWriter, r *http.Request) (*db.Repo, *db.IssueRef, int64, bool) { user := User(r) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return nil, nil, 0, false } if s.limited(w, r, labelWriteLimiter, false) { return nil, nil, 0, false } repo, ok := s.visibleRepo(w, r) if !ok { return nil, nil, 0, false } num := issueNumber(r) issue, ok := s.issueRef(w, r, repo.ID, num) if !ok { return nil, nil, 0, false } canManage := user.IsAdmin || (repo.AllowUserLabels && issue.AuthorID != nil && user.ID == *issue.AuthorID) if !canManage { http.Error(w, "Forbidden", http.StatusForbidden) return nil, nil, 0, false } return repo, issue, num, true } // issueRef loads the small issue row and writes a 404 when it is missing. func (s *Server) issueRef(w http.ResponseWriter, r *http.Request, repoID, number int64) (*db.IssueRef, bool) { issue, err := s.DB.IssueRefByNumber(r.Context(), repoID, number) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return nil, false } if issue == nil { http.Error(w, "Not found", http.StatusNotFound) return nil, false } return issue, true }