patches.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "io"
9 "log"
10 "net/http"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/go-chi/chi/v5"
16
17 "hearthforge/internal/db"
18 "hearthforge/internal/gitcmd"
19 "hearthforge/internal/util"
20 "hearthforge/internal/web/views"
21)
22
23const patchesPerPage = 20
24
25// multipartMemory is how much of an upload is kept in memory. The rest goes
26// to a temp file. The request body itself is capped by the bodyLimit
27// middleware with MAX_UPLOAD_BYTES.
28const multipartMemory = 1 << 20
29
30func (s *Server) patchRoutes(r chi.Router) {
31 r.Get("/{repo}/patches", s.patchList)
32 r.Get("/{repo}/patches/{number}", s.patchDetail)
33
34 r.Group(func(r chi.Router) {
35 r.Use(s.requireAuth)
36 r.Get("/{repo}/patches/new", s.newPatch)
37 r.Post("/{repo}/patches", s.createPatch)
38 r.Post("/{repo}/patches/{number}/upload", s.uploadPatchFile)
39 r.Post("/{repo}/patches/{number}/comments", s.addPatchComment)
40 r.Post("/{repo}/patches/{number}/comments/{id}/edit", s.editPatchComment)
41 r.Post("/{repo}/patches/{number}/react", s.reactPatch)
42 r.Post("/{repo}/patches/{number}/edit", s.editPatch)
43 r.Post("/{repo}/patches/{number}/delete", s.deletePatch)
44 })
45
46 r.Group(func(r chi.Router) {
47 r.Use(s.requireAdmin)
48 r.Post("/{repo}/patches/{number}/merge", s.mergePatch)
49 r.Post("/{repo}/patches/{number}/close", s.closePatch)
50 })
51
52 // The label routes answer 401 instead of redirecting, so they do their
53 // own auth check.
54 r.Post("/{repo}/patches/{number}/labels/add", s.addPatchLabel)
55 r.Post("/{repo}/patches/{number}/labels/remove", s.removePatchLabel)
56}
57
58// looksLikePatch checks for a line that only a diff has.
59func looksLikePatch(content string) bool {
60 for _, l := range strings.Split(content, "\n") {
61 if strings.HasPrefix(l, "diff --git ") || strings.HasPrefix(l, "--- ") ||
62 strings.HasPrefix(l, "+++ ") || strings.HasPrefix(l, "@@ ") ||
63 strings.HasPrefix(l, "Index: ") {
64 return true
65 }
66 }
67 return false
68}
69
70// newVersion is the opaque token that guards against merging a patch file the
71// admin did not review.
72func newVersion() string {
73 b := make([]byte, 16)
74 rand.Read(b)
75 return hex.EncodeToString(b)
76}
77
78// patchCheckError is the cached marker for a check that could not run. It is
79// never shown, it only stops the next view from spawning git again.
80const patchCheckError = "error"
81
82const (
83 patchCheckTimeout = time.Minute
84 patchCheckErrorTTL = time.Minute
85)
86
87// runPatchCheck previews the patch and caches the result.
88func (s *Server) runPatchCheck(r *http.Request, repoName string, patchID int64,
89 content string,
90) *gitcmd.ApplyResult {
91 // A client disconnect must not cancel the check and cache its error.
92 ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), patchCheckTimeout)
93 defer cancel()
94 result, err := s.Git.CheckPatch(ctx, repoName, content)
95 if err != nil {
96 // Cache the failure briefly, so views of a broken patch do not spawn
97 // git on every request, and a transient error clears soon.
98 s.Patches.SetTTL(patchID, gitcmd.ApplyResult{Status: patchCheckError}, patchCheckErrorTTL)
99 return nil
100 }
101 s.Patches.Set(patchID, result)
102 return &result
103}
104
105// patchRef loads the small patch row and writes a 404 when it is missing.
106func (s *Server) patchRef(w http.ResponseWriter, r *http.Request, repoID, number int64) (*db.PatchRef, bool) {
107 patch, err := s.DB.PatchRefByNumber(r.Context(), repoID, number)
108 if err != nil {
109 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
110 return nil, false
111 }
112 if patch == nil {
113 http.Error(w, "Not found", http.StatusNotFound)
114 return nil, false
115 }
116 return patch, true
117}
118
119func (s *Server) patchList(w http.ResponseWriter, r *http.Request) {
120 repo, ok := s.visibleRepo(w, r)
121 if !ok {
122 return
123 }
124 q := r.URL.Query()
125 status := "open"
126 switch q.Get("status") {
127 case "merged":
128 status = "merged"
129 case "closed":
130 status = "closed"
131 }
132 labelIDs := parseLabelIDs(q["labels"])
133
134 repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID)
135 if err != nil {
136 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
137 return
138 }
139 counts, err := s.DB.PatchCounts(r.Context(), repo.ID, labelIDs)
140 if err != nil {
141 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
142 return
143 }
144 page := util.Paginate(util.ParsePage(q.Get("page")), counts[status], patchesPerPage)
145 patches, err := s.DB.ListPatches(r.Context(), repo.ID, status, labelIDs, patchesPerPage, page.Offset)
146 if err != nil {
147 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
148 return
149 }
150 ids := make([]int64, len(patches))
151 for i, p := range patches {
152 ids[i] = p.ID
153 }
154 labelsByPatch, err := s.DB.PatchLabelsByPatch(r.Context(), ids)
155 if err != nil {
156 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
157 return
158 }
159
160 pageInfo := views.PageInfo{
161 Page: page.Page,
162 TotalPages: page.TotalPages,
163 URLTemplate: "/" + repo.Name + "/patches?status=" + status +
164 views.LabelsQueryParam(labelIDs) + "&page={page}",
165 }
166 views.Render(w, http.StatusOK, views.PatchList(s.Cfg, User(r), repo, patches, status,
167 counts, pageInfo, repoLabels, labelIDs, labelsByPatch))
168}
169
170func (s *Server) newPatch(w http.ResponseWriter, r *http.Request) {
171 repo, ok := s.visibleRepo(w, r)
172 if !ok {
173 return
174 }
175 labels, err := s.DB.ListLabels(r.Context(), repo.ID)
176 if err != nil {
177 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
178 return
179 }
180 template := ""
181 if repo.PatchTemplate != nil {
182 template = *repo.PatchTemplate
183 }
184 views.Render(w, http.StatusOK, views.NewPatch(s.Cfg, User(r), repo, "", template, labels))
185}
186
187func (s *Server) createPatch(w http.ResponseWriter, r *http.Request) {
188 if s.limited(w, r, patchCreateLimiter, false) || s.limited(w, r, uploadLimiter, false) {
189 return
190 }
191 repo, ok := s.visibleRepo(w, r)
192 if !ok {
193 return
194 }
195 user := User(r)
196
197 // fail re-renders the form with an error message.
198 fail := func(msg string) {
199 labels, err := s.DB.ListLabels(r.Context(), repo.ID)
200 if err != nil {
201 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
202 return
203 }
204 views.Render(w, http.StatusOK, views.NewPatch(s.Cfg, user, repo, msg, "", labels))
205 }
206
207 if err := parseUploadForm(r, multipartMemory); err != nil {
208 fail("Patch file is required")
209 return
210 }
211 title := r.FormValue("title")
212 description := r.FormValue("description")
213 if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, description, s.Cfg.MaxTextBodyBytes) {
214 return
215 }
216 if strings.TrimSpace(title) == "" {
217 fail("Title is required")
218 return
219 }
220
221 content, msg := s.readPatchUpload(r)
222 if msg != "" {
223 fail(msg)
224 return
225 }
226 meta := gitcmd.ExtractPatchMeta(content)
227 if msg := checkPatchHeaders(meta); msg != "" {
228 fail(msg)
229 return
230 }
231
232 var labelIDs []int64
233 if user.IsAdmin || repo.AllowUserLabels {
234 labelIDs = parseLabelIDs(r.Form["label_ids"])
235 }
236 number, id, err := s.DB.CreatePatch(r.Context(), repo.ID, &user.ID, strings.TrimSpace(title),
237 strings.TrimSpace(description), content, meta.Author, meta.Email, newVersion(),
238 db.NowISO(), labelIDs)
239 if err != nil {
240 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
241 return
242 }
243 s.runPatchCheck(r, repo.Name, id, content)
244 redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(number, 10))
245}
246
247// readPatchUpload reads the patch_file part. It returns the content, or an
248// error message for the user.
249func (s *Server) readPatchUpload(r *http.Request) (content, errMsg string) {
250 file, header, err := r.FormFile("patch_file")
251 if err != nil || header.Size == 0 {
252 return "", "Patch file is required"
253 }
254 defer file.Close()
255 if header.Size > s.Cfg.MaxUserUploadBytes {
256 return "", "Patch file is too large"
257 }
258 raw, err := io.ReadAll(file)
259 if err != nil {
260 return "", "Patch file is required"
261 }
262 content = string(raw)
263 if strings.TrimSpace(content) == "" {
264 return "", "Patch file is empty"
265 }
266 if !looksLikePatch(content) {
267 return "", "File does not appear to be a valid patch file"
268 }
269 return content, ""
270}
271
272// checkPatchHeaders reports what a `git format-patch` header block is missing.
273func checkPatchHeaders(meta gitcmd.PatchMeta) string {
274 switch {
275 case meta.Subject == "":
276 return "Patch is missing a Subject header. Make sure to upload a patch created with git format-patch."
277 case meta.Author == "" || meta.Email == "":
278 return "Patch is missing a From header with name and email."
279 case meta.Date == "":
280 return "Patch is missing a Date header."
281 }
282 return ""
283}
284
285func (s *Server) patchDetail(w http.ResponseWriter, r *http.Request) {
286 repo, ok := s.visibleRepo(w, r)
287 if !ok {
288 return
289 }
290 num, _ := leadingInt(chi.URLParam(r, "number"))
291 patch, err := s.DB.PatchByNumber(r.Context(), repo.ID, num)
292 if err != nil {
293 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
294 return
295 }
296 if patch == nil {
297 http.Error(w, "Not found", http.StatusNotFound)
298 return
299 }
300 user := User(r)
301 viewerID := int64(0)
302 if user != nil {
303 viewerID = user.ID
304 }
305
306 var applyResult *gitcmd.ApplyResult
307 if cached, ok := s.Patches.Get(patch.ID); ok {
308 if cached.Status != patchCheckError {
309 applyResult = &cached
310 }
311 } else if patch.Status == "open" {
312 // Cold cache, for example after a restart. Only open patches still
313 // need an answer, so re-check them here.
314 applyResult = s.runPatchCheck(r, repo.Name, patch.ID, patch.PatchContent)
315 }
316
317 // The version is part of the key so a re-uploaded patch is not served
318 // from the diff cache.
319 files := s.HL.PrepareDiff(patch.PatchContent,
320 "patch:"+strconv.FormatInt(patch.ID, 10)+":"+patch.Version, nil)
321
322 comments, err := s.DB.ListPatchComments(r.Context(), patch.ID)
323 if err != nil {
324 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
325 return
326 }
327 reactionRows, err := s.DB.ListPatchReactions(r.Context(), patch.ID)
328 if err != nil {
329 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
330 return
331 }
332 patchLabels, err := s.DB.PatchLabels(r.Context(), patch.ID)
333 if err != nil {
334 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
335 return
336 }
337 repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID)
338 if err != nil {
339 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
340 return
341 }
342
343 thread := make([]views.ThreadComment, len(comments))
344 commentReactions := make(map[int64][]views.ReactionCount, len(comments))
345 for i, c := range comments {
346 username := ""
347 if c.AuthorUsername != nil {
348 username = *c.AuthorUsername
349 }
350 thread[i] = views.ThreadComment{
351 ID: c.ID,
352 AuthorID: c.AuthorID,
353 AuthorUsername: username,
354 AuthorAvatarVersion: c.AuthorAvatarVersion,
355 Body: c.Body,
356 BodyHTML: s.MD.Render(c.Body, "", nil),
357 CreatedAt: c.CreatedAt,
358 EditedAt: c.EditedAt,
359 }
360 id := c.ID
361 commentReactions[c.ID] = groupReactions(reactionRows, &id, viewerID)
362 }
363
364 tab := "conversation"
365 if r.URL.Query().Get("tab") == "changes" {
366 tab = "changes"
367 }
368 descriptionHTML := ""
369 if patch.Description != "" {
370 descriptionHTML = s.MD.Render(patch.Description, "", nil)
371 }
372
373 views.Render(w, http.StatusOK, views.PatchDetail(s.Cfg, user, repo, patch, descriptionHTML,
374 applyResult, files, tab, gitcmd.ExtractPatchMeta(patch.PatchContent), thread,
375 groupReactions(reactionRows, nil, viewerID), commentReactions, patchLabels, repoLabels))
376}
377
378func (s *Server) mergePatch(w http.ResponseWriter, r *http.Request) {
379 repo, ok := s.visibleRepo(w, r)
380 if !ok {
381 return
382 }
383 num, _ := leadingInt(chi.URLParam(r, "number"))
384 patch, ok := s.patchRef(w, r, repo.ID, num)
385 if !ok {
386 return
387 }
388 // Reject if the patch file changed after the admin loaded the page.
389 if r.FormValue("version") != patch.Version {
390 http.Error(w, "The patch file was updated after you loaded this page. "+
391 "Please review the new version before merging.", http.StatusConflict)
392 return
393 }
394 // Claim the merge before the slow git call so two requests cannot both
395 // apply the same patch.
396 claimed, err := s.DB.ClaimPatchMerge(r.Context(), patch.ID, patch.Version, db.NowISO())
397 if err != nil {
398 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
399 return
400 }
401 if !claimed {
402 http.Error(w, "Patch is not open", http.StatusBadRequest)
403 return
404 }
405
406 meta := gitcmd.ExtractPatchMeta(patch.PatchContent)
407 // The merge is already claimed in the database, so it must finish even
408 // when the client disconnects. A cancelled context would leave a
409 // half-applied merge and a patch row nobody can reopen.
410 mergeCtx := context.WithoutCancel(r.Context())
411 _, err = s.Git.ApplyPatch(mergeCtx, repo.Name, patch.PatchContent,
412 gitcmd.Ident{Name: meta.Author, Email: meta.Email},
413 gitcmd.Ident{Name: s.Cfg.CommitterName, Email: s.Cfg.CommitterEmail})
414 if err != nil {
415 // Roll the status back so the patch stays mergeable.
416 if rerr := s.DB.ReopenPatch(mergeCtx, patch.ID, db.NowISO()); rerr != nil {
417 log.Printf("reopen patch %d after failed apply: %v", patch.ID, rerr)
418 }
419 if errors.Is(err, gitcmd.ErrConflict) {
420 http.Error(w, "Patch does not apply", http.StatusConflict)
421 return
422 }
423 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
424 return
425 }
426 s.Patches.Delete(patch.ID)
427 s.Git.InvalidateRefCache(repo.Name)
428 redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10))
429}
430
431func (s *Server) uploadPatchFile(w http.ResponseWriter, r *http.Request) {
432 if s.limited(w, r, patchCreateLimiter, false) || s.limited(w, r, uploadLimiter, false) {
433 return
434 }
435 repo, ok := s.visibleRepo(w, r)
436 if !ok {
437 return
438 }
439 num, _ := leadingInt(chi.URLParam(r, "number"))
440 patch, ok := s.patchRef(w, r, repo.ID, num)
441 if !ok {
442 return
443 }
444 user := User(r)
445 if (patch.AuthorID == nil || *patch.AuthorID != user.ID) && !user.IsAdmin {
446 http.Error(w, "Forbidden", http.StatusForbidden)
447 return
448 }
449 if patch.Status != "open" {
450 http.Error(w, "Patch is not open", http.StatusBadRequest)
451 return
452 }
453 if err := parseUploadForm(r, multipartMemory); err != nil {
454 http.Error(w, "Patch file is required", http.StatusBadRequest)
455 return
456 }
457 content, msg := s.readPatchUpload(r)
458 if msg != "" {
459 http.Error(w, msg, http.StatusBadRequest)
460 return
461 }
462 meta := gitcmd.ExtractPatchMeta(content)
463 if meta.Subject == "" || meta.Author == "" || meta.Email == "" || meta.Date == "" {
464 http.Error(w, "Patch is missing required headers (Subject, From, Date)",
465 http.StatusBadRequest)
466 return
467 }
468 replaced, err := s.DB.ReplacePatchContent(r.Context(), patch.ID, content, meta.Author, meta.Email,
469 newVersion(), db.NowISO())
470 if err != nil {
471 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
472 return
473 }
474 if !replaced {
475 // The patch was closed or merged since the check above.
476 http.Error(w, "Patch is not open", http.StatusConflict)
477 return
478 }
479 s.Patches.Delete(patch.ID)
480 s.runPatchCheck(r, repo.Name, patch.ID, content)
481 redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10))
482}
483
484func (s *Server) closePatch(w http.ResponseWriter, r *http.Request) {
485 repo, ok := s.visibleRepo(w, r)
486 if !ok {
487 return
488 }
489 num, _ := leadingInt(chi.URLParam(r, "number"))
490 patch, ok := s.patchRef(w, r, repo.ID, num)
491 if !ok {
492 return
493 }
494 toggled, err := s.DB.TogglePatchClosed(r.Context(), patch.ID, db.NowISO())
495 if err != nil {
496 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
497 return
498 }
499 if !toggled {
500 http.Error(w, "Patch is merged", http.StatusBadRequest)
501 return
502 }
503 redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10))
504}
505
506func (s *Server) deletePatch(w http.ResponseWriter, r *http.Request) {
507 repo, ok := s.visibleRepo(w, r)
508 if !ok {
509 return
510 }
511 num, _ := leadingInt(chi.URLParam(r, "number"))
512 patch, ok := s.patchRef(w, r, repo.ID, num)
513 if !ok {
514 return
515 }
516 user := User(r)
517 if !views.CanEdit(user, patch.AuthorID, patch.Status) {
518 http.Error(w, "Forbidden", http.StatusForbidden)
519 return
520 }
521 s.Patches.Delete(patch.ID)
522 if err := s.DB.DeletePatch(r.Context(), patch.ID); err != nil {
523 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
524 return
525 }
526 redirectTo(w, r, "/"+repo.Name+"/patches")
527}
528
529func (s *Server) addPatchComment(w http.ResponseWriter, r *http.Request) {
530 if s.limited(w, r, commentLimiter, false) {
531 return
532 }
533 repo, ok := s.visibleRepo(w, r)
534 if !ok {
535 return
536 }
537 num, _ := leadingInt(chi.URLParam(r, "number"))
538 patch, ok := s.patchRef(w, r, repo.ID, num)
539 if !ok {
540 return
541 }
542 target := "/" + repo.Name + "/patches/" + strconv.FormatInt(num, 10)
543 user := User(r)
544 // Only an admin may comment on a closed or merged patch.
545 if patch.Status != "open" && !user.IsAdmin {
546 redirectTo(w, r, target)
547 return
548 }
549 body := r.FormValue("body")
550 if tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
551 return
552 }
553 if strings.TrimSpace(body) == "" {
554 redirectTo(w, r, target)
555 return
556 }
557 if err := s.DB.AddPatchComment(r.Context(), patch.ID, &user.ID,
558 strings.TrimSpace(body), db.NowISO()); err != nil {
559 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
560 return
561 }
562 redirectTo(w, r, target)
563}
564
565func (s *Server) editPatchComment(w http.ResponseWriter, r *http.Request) {
566 if s.limited(w, r, commentLimiter, false) {
567 return
568 }
569 repo, ok := s.visibleRepo(w, r)
570 if !ok {
571 return
572 }
573 commentID, _ := leadingInt(chi.URLParam(r, "id"))
574 auth, err := s.DB.PatchCommentAuth(r.Context(), commentID, repo.ID)
575 if err != nil {
576 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
577 return
578 }
579 if auth == nil {
580 http.Error(w, "Not found", http.StatusNotFound)
581 return
582 }
583 user := User(r)
584 if !views.CanEdit(user, auth.AuthorID, auth.Status) {
585 http.Error(w, "Forbidden", http.StatusForbidden)
586 return
587 }
588 body := r.FormValue("edit_body")
589 if tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
590 return
591 }
592 if strings.TrimSpace(body) == "" {
593 http.Error(w, "Comment is required", http.StatusUnprocessableEntity)
594 return
595 }
596 if err := s.DB.UpdatePatchComment(r.Context(), commentID, strings.TrimSpace(body), db.NowISO()); err != nil {
597 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
598 return
599 }
600 redirectTo(w, r, "/"+repo.Name+"/patches/"+chi.URLParam(r, "number"))
601}
602
603func (s *Server) reactPatch(w http.ResponseWriter, r *http.Request) {
604 if s.limited(w, r, reactionLimiter, false) {
605 return
606 }
607 repo, ok := s.visibleRepo(w, r)
608 if !ok {
609 return
610 }
611 emoji := r.FormValue("emoji")
612 if !allowedReaction(emoji) {
613 http.Error(w, "Invalid emoji", http.StatusBadRequest)
614 return
615 }
616 num, _ := leadingInt(chi.URLParam(r, "number"))
617 patch, ok := s.patchRef(w, r, repo.ID, num)
618 if !ok {
619 return
620 }
621 if !toggleReaction(w, r, func(commentID *int64) error {
622 return s.DB.TogglePatchReaction(r.Context(), patch.ID, commentID, User(r).ID, emoji)
623 }) {
624 return
625 }
626 http.Redirect(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10), http.StatusSeeOther)
627}
628
629func (s *Server) editPatch(w http.ResponseWriter, r *http.Request) {
630 if s.limited(w, r, commentLimiter, false) {
631 return
632 }
633 repo, ok := s.visibleRepo(w, r)
634 if !ok {
635 return
636 }
637 num, _ := leadingInt(chi.URLParam(r, "number"))
638 patch, ok := s.patchRef(w, r, repo.ID, num)
639 if !ok {
640 return
641 }
642 user := User(r)
643 if !views.CanEdit(user, patch.AuthorID, patch.Status) {
644 http.Error(w, "Forbidden", http.StatusForbidden)
645 return
646 }
647 title := r.FormValue("title")
648 description := r.FormValue("edit_description")
649 if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, description, s.Cfg.MaxTextBodyBytes) {
650 return
651 }
652 if strings.TrimSpace(title) == "" {
653 http.Error(w, "Title is required", http.StatusUnprocessableEntity)
654 return
655 }
656 if err := s.DB.UpdatePatch(r.Context(), patch.ID, strings.TrimSpace(title),
657 description, db.NowISO()); err != nil {
658 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
659 return
660 }
661 redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10))
662}
663
664func (s *Server) addPatchLabel(w http.ResponseWriter, r *http.Request) {
665 repo, patch, num, ok := s.patchLabelTarget(w, r)
666 if !ok {
667 return
668 }
669 labelID, _ := leadingInt(r.FormValue("label_id"))
670 target := "/" + repo.Name + "/patches/" + strconv.FormatInt(num, 10)
671 label, err := s.DB.LabelInRepo(r.Context(), labelID, repo.ID)
672 if err != nil {
673 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
674 return
675 }
676 if label == nil {
677 redirectTo(w, r, target)
678 return
679 }
680 if err := s.DB.AddPatchLabel(r.Context(), patch.ID, label.ID); err != nil {
681 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
682 return
683 }
684 redirectTo(w, r, target)
685}
686
687func (s *Server) removePatchLabel(w http.ResponseWriter, r *http.Request) {
688 repo, patch, num, ok := s.patchLabelTarget(w, r)
689 if !ok {
690 return
691 }
692 labelID, _ := leadingInt(r.FormValue("label_id"))
693 if err := s.DB.RemovePatchLabel(r.Context(), patch.ID, labelID); err != nil {
694 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
695 return
696 }
697 redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10))
698}
699
700// patchLabelTarget runs the shared checks of the label add and remove routes.
701func (s *Server) patchLabelTarget(w http.ResponseWriter, r *http.Request) (*db.Repo, *db.PatchRef, int64, bool) {
702 user := User(r)
703 if user == nil {
704 http.Error(w, "Unauthorized", http.StatusUnauthorized)
705 return nil, nil, 0, false
706 }
707 if s.limited(w, r, labelWriteLimiter, false) {
708 return nil, nil, 0, false
709 }
710 repo, ok := s.visibleRepo(w, r)
711 if !ok {
712 return nil, nil, 0, false
713 }
714 num, _ := leadingInt(chi.URLParam(r, "number"))
715 patch, ok := s.patchRef(w, r, repo.ID, num)
716 if !ok {
717 return nil, nil, 0, false
718 }
719 canManage := user.IsAdmin ||
720 (repo.AllowUserLabels && patch.AuthorID != nil && user.ID == *patch.AuthorID)
721 if !canManage {
722 http.Error(w, "Forbidden", http.StatusForbidden)
723 return nil, nil, 0, false
724 }
725 return repo, patch, num, true
726}
727