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