issues.go
⎇
Raw
1package web
2
3import (
4 "net/http"
5 "os"
6 "slices"
7 "strconv"
8 "strings"
9
10 "github.com/go-chi/chi/v5"
11
12 "hearthforge/internal/db"
13 "hearthforge/internal/util"
14 "hearthforge/internal/web/views"
15)
16
17const issuesPerPage = 20
18
19// allowedReaction reports whether the emoji is in the picker set.
20func allowedReaction(emoji string) bool {
21 return slices.Contains(views.AllowedReactions, emoji)
22}
23
24// visibleRepo loads the {repo} URL parameter and hides private repos from
25// non-admins. It writes a 404 and returns false when the repo is not visible.
26func (s *Server) visibleRepo(w http.ResponseWriter, r *http.Request) (*db.Repo, bool) {
27 u := User(r)
28 repo, err := s.DB.GetRepo(r.Context(), chi.URLParam(r, "repo"), u != nil && u.IsAdmin)
29 if err != nil {
30 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
31 return nil, false
32 }
33 if repo == nil {
34 http.Error(w, "Not found", http.StatusNotFound)
35 return nil, false
36 }
37 if !s.repoOnDisk(w, repo.Name) {
38 return nil, false
39 }
40 return repo, true
41}
42
43// repoOnDisk writes a 404 and returns false when the git directory is gone.
44// The row stays until an admin drops it from the settings page.
45func (s *Server) repoOnDisk(w http.ResponseWriter, name string) bool {
46 if _, err := os.Stat(s.Git.RepoPath(name)); os.IsNotExist(err) {
47 http.Error(w, "Repository directory is missing on disk", http.StatusNotFound)
48 return false
49 }
50 return true
51}
52
53// leadingInt parses the digits at the start of s, like JavaScript's parseInt.
54// It returns false when s does not start with a number.
55func leadingInt(s string) (int64, bool) {
56 end := 0
57 for end < len(s) && s[end] >= '0' && s[end] <= '9' {
58 end++
59 }
60 if end == 0 {
61 return 0, false
62 }
63 n, err := strconv.ParseInt(s[:end], 10, 64)
64 return n, err == nil
65}
66
67// parseLabelIDs turns repeated form or query values into label ids.
68func parseLabelIDs(values []string) []int64 {
69 var out []int64
70 for _, v := range values {
71 if n, ok := leadingInt(v); ok {
72 out = append(out, n)
73 }
74 }
75 return out
76}
77
78// groupReactions counts the reactions on one target. commentID is nil for the
79// issue or patch body itself. userID is 0 for anonymous viewers.
80func groupReactions(reactions []db.Reaction, commentID *int64, userID int64) []views.ReactionCount {
81 var out []views.ReactionCount
82 index := map[string]int{}
83 for _, r := range reactions {
84 if commentID == nil {
85 if r.CommentID != nil {
86 continue
87 }
88 } else if r.CommentID == nil || *r.CommentID != *commentID {
89 continue
90 }
91 i, ok := index[r.Emoji]
92 if !ok {
93 i = len(out)
94 index[r.Emoji] = i
95 out = append(out, views.ReactionCount{Emoji: r.Emoji})
96 }
97 out[i].Count++
98 if userID != 0 && r.UserID == userID {
99 out[i].UserReacted = true
100 }
101 }
102 return out
103}
104
105// issueNumber reads the {number} URL parameter.
106func issueNumber(r *http.Request) int64 {
107 n, _ := leadingInt(chi.URLParam(r, "number"))
108 return n
109}
110
111// tooLong rejects a field that exceeds its byte cap.
112func tooLong(w http.ResponseWriter, value string, max int) bool {
113 if len(value) <= max {
114 return false
115 }
116 http.Error(w, "Bad Request", http.StatusUnprocessableEntity)
117 return true
118}
119
120func (s *Server) issueRoutes(r chi.Router) {
121 r.Get("/{repo}/issues", s.issueList)
122 r.Get("/{repo}/issues/{number}", s.issueDetail)
123
124 r.Group(func(r chi.Router) {
125 r.Use(s.requireAuth)
126 r.Get("/{repo}/issues/new", s.newIssue)
127 r.Post("/{repo}/issues", s.createIssue)
128 r.Post("/{repo}/issues/{number}/comments", s.addIssueComment)
129 r.Post("/{repo}/issues/{number}/comments/{id}/edit", s.editIssueComment)
130 r.Post("/{repo}/issues/{number}/react", s.reactIssue)
131 r.Post("/{repo}/issues/{number}/delete", s.deleteIssue)
132 r.Post("/{repo}/issues/{number}/edit", s.editIssue)
133 })
134
135 r.Group(func(r chi.Router) {
136 r.Use(s.requireAdmin)
137 r.Post("/{repo}/issues/{number}/complete", s.completeIssue)
138 r.Post("/{repo}/issues/{number}/close", s.closeIssue)
139 })
140
141 // The label routes answer 401 instead of redirecting, so they do their
142 // own auth check.
143 r.Post("/{repo}/issues/{number}/labels/add", s.addIssueLabel)
144 r.Post("/{repo}/issues/{number}/labels/remove", s.removeIssueLabel)
145}
146
147func (s *Server) issueList(w http.ResponseWriter, r *http.Request) {
148 repo, ok := s.visibleRepo(w, r)
149 if !ok {
150 return
151 }
152 q := r.URL.Query()
153 status := "open"
154 switch q.Get("status") {
155 case "closed":
156 status = "closed"
157 case "completed":
158 status = "completed"
159 }
160 labelIDs := parseLabelIDs(q["labels"])
161
162 repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID)
163 if err != nil {
164 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
165 return
166 }
167 counts, err := s.DB.IssueCounts(r.Context(), repo.ID, labelIDs)
168 if err != nil {
169 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
170 return
171 }
172 page := util.Paginate(util.ParsePage(q.Get("page")), counts[status], issuesPerPage)
173 issues, err := s.DB.ListIssues(r.Context(), repo.ID, status, labelIDs, issuesPerPage, page.Offset)
174 if err != nil {
175 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
176 return
177 }
178 ids := make([]int64, len(issues))
179 for i, issue := range issues {
180 ids[i] = issue.ID
181 }
182 labelsByIssue, err := s.DB.IssueLabelsByIssue(r.Context(), ids)
183 if err != nil {
184 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
185 return
186 }
187
188 pageInfo := views.PageInfo{
189 Page: page.Page,
190 TotalPages: page.TotalPages,
191 URLTemplate: "/" + repo.Name + "/issues?status=" + status +
192 views.LabelsQueryParam(labelIDs) + "&page={page}",
193 }
194 views.Render(w, http.StatusOK, views.IssueList(s.Cfg, User(r), repo, issues, status,
195 counts, pageInfo, repoLabels, labelIDs, labelsByIssue))
196}
197
198func (s *Server) newIssue(w http.ResponseWriter, r *http.Request) {
199 repo, ok := s.visibleRepo(w, r)
200 if !ok {
201 return
202 }
203 labels, err := s.DB.ListLabels(r.Context(), repo.ID)
204 if err != nil {
205 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
206 return
207 }
208 template := ""
209 if repo.IssueTemplate != nil {
210 template = *repo.IssueTemplate
211 }
212 views.Render(w, http.StatusOK, views.NewIssue(s.Cfg, User(r), repo, "", template, labels))
213}
214
215func (s *Server) createIssue(w http.ResponseWriter, r *http.Request) {
216 if s.limited(w, r, issueCreateLimiter, false) {
217 return
218 }
219 repo, ok := s.visibleRepo(w, r)
220 if !ok {
221 return
222 }
223 user := User(r)
224 title := r.FormValue("title")
225 body := r.FormValue("body")
226 if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
227 return
228 }
229 if strings.TrimSpace(title) == "" {
230 labels, err := s.DB.ListLabels(r.Context(), repo.ID)
231 if err != nil {
232 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
233 return
234 }
235 views.Render(w, http.StatusOK,
236 views.NewIssue(s.Cfg, user, repo, "Title is required", "", labels))
237 return
238 }
239
240 var labelIDs []int64
241 if user.IsAdmin || repo.AllowUserLabels {
242 labelIDs = parseLabelIDs(r.Form["label_ids"])
243 }
244 number, err := s.DB.CreateIssue(r.Context(), repo.ID, &user.ID, strings.TrimSpace(title), body,
245 db.NowISO(), labelIDs)
246 if err != nil {
247 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
248 return
249 }
250 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(number, 10), http.StatusFound)
251}
252
253func (s *Server) issueDetail(w http.ResponseWriter, r *http.Request) {
254 repo, ok := s.visibleRepo(w, r)
255 if !ok {
256 return
257 }
258 issue, err := s.DB.IssueByNumber(r.Context(), repo.ID, issueNumber(r))
259 if err != nil {
260 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
261 return
262 }
263 if issue == nil {
264 http.Error(w, "Not found", http.StatusNotFound)
265 return
266 }
267 user := User(r)
268 viewerID := int64(0)
269 if user != nil {
270 viewerID = user.ID
271 }
272
273 comments, err := s.DB.ListIssueComments(r.Context(), issue.ID)
274 if err != nil {
275 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
276 return
277 }
278 reactionRows, err := s.DB.ListIssueReactions(r.Context(), issue.ID)
279 if err != nil {
280 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
281 return
282 }
283 issueLabels, err := s.DB.IssueLabels(r.Context(), issue.ID)
284 if err != nil {
285 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
286 return
287 }
288 repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID)
289 if err != nil {
290 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
291 return
292 }
293
294 thread := make([]views.ThreadComment, len(comments))
295 commentReactions := make(map[int64][]views.ReactionCount, len(comments))
296 for i, c := range comments {
297 username := ""
298 if c.AuthorUsername != nil {
299 username = *c.AuthorUsername
300 }
301 thread[i] = views.ThreadComment{
302 ID: c.ID,
303 AuthorID: c.AuthorID,
304 AuthorUsername: username,
305 AuthorAvatarVersion: c.AuthorAvatarVersion,
306 Body: c.Body,
307 BodyHTML: s.MD.Render(c.Body, "", nil),
308 CreatedAt: c.CreatedAt,
309 EditedAt: c.EditedAt,
310 }
311 id := c.ID
312 commentReactions[c.ID] = groupReactions(reactionRows, &id, viewerID)
313 }
314
315 views.Render(w, http.StatusOK, views.IssueDetail(s.Cfg, user, repo, issue,
316 s.MD.Render(issue.Body, "", nil), thread,
317 groupReactions(reactionRows, nil, viewerID), commentReactions, issueLabels, repoLabels))
318}
319
320func (s *Server) addIssueComment(w http.ResponseWriter, r *http.Request) {
321 if s.limited(w, r, commentLimiter, false) {
322 return
323 }
324 repo, ok := s.visibleRepo(w, r)
325 if !ok {
326 return
327 }
328 num := issueNumber(r)
329 issue, ok := s.issueRef(w, r, repo.ID, num)
330 if !ok {
331 return
332 }
333 target := "/" + repo.Name + "/issues/" + strconv.FormatInt(num, 10)
334 user := User(r)
335 // Only an admin may comment on a closed issue.
336 if issue.Status == "closed" && !user.IsAdmin {
337 http.Redirect(w, r, target, http.StatusFound)
338 return
339 }
340 body := r.FormValue("body")
341 if tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
342 return
343 }
344 if strings.TrimSpace(body) == "" {
345 http.Redirect(w, r, target, http.StatusFound)
346 return
347 }
348 if err := s.DB.AddIssueComment(r.Context(), issue.ID, &user.ID, strings.TrimSpace(body), db.NowISO()); err != nil {
349 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
350 return
351 }
352 http.Redirect(w, r, target, http.StatusFound)
353}
354
355func (s *Server) editIssueComment(w http.ResponseWriter, r *http.Request) {
356 if s.limited(w, r, commentLimiter, false) {
357 return
358 }
359 repo, ok := s.visibleRepo(w, r)
360 if !ok {
361 return
362 }
363 commentID, _ := leadingInt(chi.URLParam(r, "id"))
364 auth, err := s.DB.IssueCommentAuth(r.Context(), commentID, repo.ID)
365 if err != nil {
366 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
367 return
368 }
369 if auth == nil {
370 http.Error(w, "Not found", http.StatusNotFound)
371 return
372 }
373 user := User(r)
374 if (auth.AuthorID == nil || *auth.AuthorID != user.ID) && !user.IsAdmin {
375 http.Error(w, "Forbidden", http.StatusForbidden)
376 return
377 }
378 if auth.Status != "open" && !user.IsAdmin {
379 http.Error(w, "Forbidden", http.StatusForbidden)
380 return
381 }
382 body := r.FormValue("edit_body")
383 if tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
384 return
385 }
386 if strings.TrimSpace(body) == "" {
387 http.Error(w, "Comment is required", http.StatusUnprocessableEntity)
388 return
389 }
390 if err := s.DB.UpdateIssueComment(r.Context(), commentID, strings.TrimSpace(body), db.NowISO()); err != nil {
391 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
392 return
393 }
394 http.Redirect(w, r, "/"+repo.Name+"/issues/"+chi.URLParam(r, "number"), http.StatusFound)
395}
396
397func (s *Server) reactIssue(w http.ResponseWriter, r *http.Request) {
398 if s.limited(w, r, reactionLimiter, false) {
399 return
400 }
401 repo, ok := s.visibleRepo(w, r)
402 if !ok {
403 return
404 }
405 emoji := r.FormValue("emoji")
406 if !allowedReaction(emoji) {
407 http.Error(w, "Invalid emoji", http.StatusBadRequest)
408 return
409 }
410 num := issueNumber(r)
411 issue, ok := s.issueRef(w, r, repo.ID, num)
412 if !ok {
413 return
414 }
415 var commentID *int64
416 if raw := r.FormValue("comment_id"); raw != "" {
417 if n, ok := leadingInt(raw); ok {
418 commentID = &n
419 }
420 }
421 if err := s.DB.ToggleIssueReaction(r.Context(), issue.ID, commentID, User(r).ID, emoji); err != nil {
422 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
423 return
424 }
425 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusSeeOther)
426}
427
428func (s *Server) completeIssue(w http.ResponseWriter, r *http.Request) {
429 repo, ok := s.visibleRepo(w, r)
430 if !ok {
431 return
432 }
433 num := issueNumber(r)
434 issue, ok := s.issueRef(w, r, repo.ID, num)
435 if !ok {
436 return
437 }
438 if err := s.DB.CompleteIssue(r.Context(), issue.ID, db.NowISO()); err != nil {
439 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
440 return
441 }
442 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
443}
444
445func (s *Server) closeIssue(w http.ResponseWriter, r *http.Request) {
446 repo, ok := s.visibleRepo(w, r)
447 if !ok {
448 return
449 }
450 num := issueNumber(r)
451 issue, ok := s.issueRef(w, r, repo.ID, num)
452 if !ok {
453 return
454 }
455 if err := s.DB.ToggleIssueClosed(r.Context(), issue.ID, db.NowISO()); err != nil {
456 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
457 return
458 }
459 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
460}
461
462func (s *Server) deleteIssue(w http.ResponseWriter, r *http.Request) {
463 repo, ok := s.visibleRepo(w, r)
464 if !ok {
465 return
466 }
467 issue, ok := s.issueRef(w, r, repo.ID, issueNumber(r))
468 if !ok {
469 return
470 }
471 user := User(r)
472 if (issue.AuthorID == nil || *issue.AuthorID != user.ID) && !user.IsAdmin {
473 http.Error(w, "Forbidden", http.StatusForbidden)
474 return
475 }
476 if err := s.DB.DeleteIssue(r.Context(), issue.ID); err != nil {
477 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
478 return
479 }
480 http.Redirect(w, r, "/"+repo.Name+"/issues", http.StatusFound)
481}
482
483func (s *Server) editIssue(w http.ResponseWriter, r *http.Request) {
484 if s.limited(w, r, commentLimiter, false) {
485 return
486 }
487 repo, ok := s.visibleRepo(w, r)
488 if !ok {
489 return
490 }
491 num := issueNumber(r)
492 issue, ok := s.issueRef(w, r, repo.ID, num)
493 if !ok {
494 return
495 }
496 user := User(r)
497 if (issue.AuthorID == nil || *issue.AuthorID != user.ID) && !user.IsAdmin {
498 http.Error(w, "Forbidden", http.StatusForbidden)
499 return
500 }
501 if issue.Status != "open" && !user.IsAdmin {
502 http.Error(w, "Forbidden", http.StatusForbidden)
503 return
504 }
505 title := r.FormValue("title")
506 body := r.FormValue("edit_body")
507 if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
508 return
509 }
510 if strings.TrimSpace(title) == "" {
511 http.Error(w, "Title is required", http.StatusUnprocessableEntity)
512 return
513 }
514 if err := s.DB.UpdateIssue(r.Context(), issue.ID, strings.TrimSpace(title), body, db.NowISO()); err != nil {
515 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
516 return
517 }
518 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
519}
520
521func (s *Server) addIssueLabel(w http.ResponseWriter, r *http.Request) {
522 repo, issue, num, ok := s.issueLabelTarget(w, r)
523 if !ok {
524 return
525 }
526 labelID, _ := leadingInt(r.FormValue("label_id"))
527 target := "/" + repo.Name + "/issues/" + strconv.FormatInt(num, 10)
528 label, err := s.DB.LabelInRepo(r.Context(), labelID, repo.ID)
529 if err != nil {
530 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
531 return
532 }
533 if label == nil {
534 http.Redirect(w, r, target, http.StatusFound)
535 return
536 }
537 if err := s.DB.AddIssueLabel(r.Context(), issue.ID, label.ID); err != nil {
538 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
539 return
540 }
541 http.Redirect(w, r, target, http.StatusFound)
542}
543
544func (s *Server) removeIssueLabel(w http.ResponseWriter, r *http.Request) {
545 repo, issue, num, ok := s.issueLabelTarget(w, r)
546 if !ok {
547 return
548 }
549 labelID, _ := leadingInt(r.FormValue("label_id"))
550 if err := s.DB.RemoveIssueLabel(r.Context(), issue.ID, labelID); err != nil {
551 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
552 return
553 }
554 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
555}
556
557// issueLabelTarget runs the shared checks of the label add and remove routes.
558func (s *Server) issueLabelTarget(w http.ResponseWriter, r *http.Request) (*db.Repo, *db.IssueRef, int64, bool) {
559 user := User(r)
560 if user == nil {
561 http.Error(w, "Unauthorized", http.StatusUnauthorized)
562 return nil, nil, 0, false
563 }
564 if s.limited(w, r, labelWriteLimiter, false) {
565 return nil, nil, 0, false
566 }
567 repo, ok := s.visibleRepo(w, r)
568 if !ok {
569 return nil, nil, 0, false
570 }
571 num := issueNumber(r)
572 issue, ok := s.issueRef(w, r, repo.ID, num)
573 if !ok {
574 return nil, nil, 0, false
575 }
576 canManage := user.IsAdmin ||
577 (repo.AllowUserLabels && issue.AuthorID != nil && user.ID == *issue.AuthorID)
578 if !canManage {
579 http.Error(w, "Forbidden", http.StatusForbidden)
580 return nil, nil, 0, false
581 }
582 return repo, issue, num, true
583}
584
585// issueRef loads the small issue row and writes a 404 when it is missing.
586func (s *Server) issueRef(w http.ResponseWriter, r *http.Request, repoID, number int64) (*db.IssueRef, bool) {
587 issue, err := s.DB.IssueRefByNumber(r.Context(), repoID, number)
588 if err != nil {
589 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
590 return nil, false
591 }
592 if issue == nil {
593 http.Error(w, "Not found", http.StatusNotFound)
594 return nil, false
595 }
596 return issue, true
597}
598