package web import ( "context" "crypto/rand" "encoding/hex" "errors" "io" "log" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/util" "hearthforge/internal/web/views" ) const patchesPerPage = 20 // multipartMemory is how much of an upload is kept in memory. The rest goes // to a temp file. The request body itself is capped by the bodyLimit // middleware with MAX_UPLOAD_BYTES. const multipartMemory = 1 << 20 func (s *Server) patchRoutes(r chi.Router) { r.Get("/{repo}/patches", s.patchList) r.Get("/{repo}/patches/{number}", s.patchDetail) r.Group(func(r chi.Router) { r.Use(s.requireAuth) r.Get("/{repo}/patches/new", s.newPatch) r.Post("/{repo}/patches", s.createPatch) r.Post("/{repo}/patches/{number}/upload", s.uploadPatchFile) r.Post("/{repo}/patches/{number}/comments", s.addPatchComment) r.Post("/{repo}/patches/{number}/comments/{id}/edit", s.editPatchComment) r.Post("/{repo}/patches/{number}/react", s.reactPatch) r.Post("/{repo}/patches/{number}/edit", s.editPatch) r.Post("/{repo}/patches/{number}/delete", s.deletePatch) }) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Post("/{repo}/patches/{number}/merge", s.mergePatch) r.Post("/{repo}/patches/{number}/close", s.closePatch) }) // The label routes answer 401 instead of redirecting, so they do their // own auth check. r.Post("/{repo}/patches/{number}/labels/add", s.addPatchLabel) r.Post("/{repo}/patches/{number}/labels/remove", s.removePatchLabel) } // looksLikePatch checks for a line that only a diff has. func looksLikePatch(content string) bool { for _, l := range strings.Split(content, "\n") { if strings.HasPrefix(l, "diff --git ") || strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") || strings.HasPrefix(l, "@@ ") || strings.HasPrefix(l, "Index: ") { return true } } return false } // newVersion is the opaque token that guards against merging a patch file the // admin did not review. func newVersion() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } // patchCheckError is the cached marker for a check that could not run. It is // never shown, it only stops the next view from spawning git again. const patchCheckError = "error" const ( patchCheckTimeout = time.Minute patchCheckErrorTTL = time.Minute ) // runPatchCheck previews the patch and caches the result. func (s *Server) runPatchCheck(r *http.Request, repoName string, patchID int64, content string, ) *gitcmd.ApplyResult { // A client disconnect must not cancel the check and cache its error. ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), patchCheckTimeout) defer cancel() result, err := s.Git.CheckPatch(ctx, repoName, content) if err != nil { // Cache the failure briefly, so views of a broken patch do not spawn // git on every request, and a transient error clears soon. s.Patches.SetTTL(patchID, gitcmd.ApplyResult{Status: patchCheckError}, patchCheckErrorTTL) return nil } s.Patches.Set(patchID, result) return &result } // patchRef loads the small patch row and writes a 404 when it is missing. func (s *Server) patchRef(w http.ResponseWriter, r *http.Request, repoID, number int64) (*db.PatchRef, bool) { patch, err := s.DB.PatchRefByNumber(r.Context(), repoID, number) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return nil, false } if patch == nil { http.Error(w, "Not found", http.StatusNotFound) return nil, false } return patch, true } func (s *Server) patchList(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 "merged": status = "merged" case "closed": status = "closed" } 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.PatchCounts(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], patchesPerPage) patches, err := s.DB.ListPatches(r.Context(), repo.ID, status, labelIDs, patchesPerPage, page.Offset) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } ids := make([]int64, len(patches)) for i, p := range patches { ids[i] = p.ID } labelsByPatch, err := s.DB.PatchLabelsByPatch(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 + "/patches?status=" + status + views.LabelsQueryParam(labelIDs) + "&page={page}", } views.Render(w, http.StatusOK, views.PatchList(s.Cfg, User(r), repo, patches, status, counts, pageInfo, repoLabels, labelIDs, labelsByPatch)) } func (s *Server) newPatch(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.PatchTemplate != nil { template = *repo.PatchTemplate } views.Render(w, http.StatusOK, views.NewPatch(s.Cfg, User(r), repo, "", template, labels)) } func (s *Server) createPatch(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, patchCreateLimiter, false) || s.limited(w, r, uploadLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } user := User(r) // fail re-renders the form with an error message. fail := func(msg string) { 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.NewPatch(s.Cfg, user, repo, msg, "", labels)) } if err := parseUploadForm(r, multipartMemory); err != nil { fail("Patch file is required") return } title := r.FormValue("title") description := r.FormValue("description") if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, description, s.Cfg.MaxTextBodyBytes) { return } if strings.TrimSpace(title) == "" { fail("Title is required") return } content, msg := s.readPatchUpload(r) if msg != "" { fail(msg) return } meta := gitcmd.ExtractPatchMeta(content) if msg := checkPatchHeaders(meta); msg != "" { fail(msg) return } var labelIDs []int64 if user.IsAdmin || repo.AllowUserLabels { labelIDs = parseLabelIDs(r.Form["label_ids"]) } number, id, err := s.DB.CreatePatch(r.Context(), repo.ID, &user.ID, strings.TrimSpace(title), strings.TrimSpace(description), content, meta.Author, meta.Email, newVersion(), db.NowISO(), labelIDs) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } s.runPatchCheck(r, repo.Name, id, content) redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(number, 10)) } // readPatchUpload reads the patch_file part. It returns the content, or an // error message for the user. func (s *Server) readPatchUpload(r *http.Request) (content, errMsg string) { file, header, err := r.FormFile("patch_file") if err != nil || header.Size == 0 { return "", "Patch file is required" } defer file.Close() if header.Size > s.Cfg.MaxUserUploadBytes { return "", "Patch file is too large" } raw, err := io.ReadAll(file) if err != nil { return "", "Patch file is required" } content = string(raw) if strings.TrimSpace(content) == "" { return "", "Patch file is empty" } if !looksLikePatch(content) { return "", "File does not appear to be a valid patch file" } return content, "" } // checkPatchHeaders reports what a `git format-patch` header block is missing. func checkPatchHeaders(meta gitcmd.PatchMeta) string { switch { case meta.Subject == "": return "Patch is missing a Subject header. Make sure to upload a patch created with git format-patch." case meta.Author == "" || meta.Email == "": return "Patch is missing a From header with name and email." case meta.Date == "": return "Patch is missing a Date header." } return "" } func (s *Server) patchDetail(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } num, _ := leadingInt(chi.URLParam(r, "number")) patch, err := s.DB.PatchByNumber(r.Context(), repo.ID, num) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if patch == nil { http.Error(w, "Not found", http.StatusNotFound) return } user := User(r) viewerID := int64(0) if user != nil { viewerID = user.ID } var applyResult *gitcmd.ApplyResult if cached, ok := s.Patches.Get(patch.ID); ok { if cached.Status != patchCheckError { applyResult = &cached } } else if patch.Status == "open" { // Cold cache, for example after a restart. Only open patches still // need an answer, so re-check them here. applyResult = s.runPatchCheck(r, repo.Name, patch.ID, patch.PatchContent) } // The version is part of the key so a re-uploaded patch is not served // from the diff cache. files := s.HL.PrepareDiff(patch.PatchContent, "patch:"+strconv.FormatInt(patch.ID, 10)+":"+patch.Version, nil) comments, err := s.DB.ListPatchComments(r.Context(), patch.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } reactionRows, err := s.DB.ListPatchReactions(r.Context(), patch.ID) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } patchLabels, err := s.DB.PatchLabels(r.Context(), patch.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) } tab := "conversation" if r.URL.Query().Get("tab") == "changes" { tab = "changes" } descriptionHTML := "" if patch.Description != "" { descriptionHTML = s.MD.Render(patch.Description, "", nil) } views.Render(w, http.StatusOK, views.PatchDetail(s.Cfg, user, repo, patch, descriptionHTML, applyResult, files, tab, gitcmd.ExtractPatchMeta(patch.PatchContent), thread, groupReactions(reactionRows, nil, viewerID), commentReactions, patchLabels, repoLabels)) } func (s *Server) mergePatch(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } num, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return } // Reject if the patch file changed after the admin loaded the page. if r.FormValue("version") != patch.Version { http.Error(w, "The patch file was updated after you loaded this page. "+ "Please review the new version before merging.", http.StatusConflict) return } // Claim the merge before the slow git call so two requests cannot both // apply the same patch. claimed, err := s.DB.ClaimPatchMerge(r.Context(), patch.ID, patch.Version, db.NowISO()) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if !claimed { http.Error(w, "Patch is not open", http.StatusBadRequest) return } meta := gitcmd.ExtractPatchMeta(patch.PatchContent) // The merge is already claimed in the database, so it must finish even // when the client disconnects. A cancelled context would leave a // half-applied merge and a patch row nobody can reopen. mergeCtx := context.WithoutCancel(r.Context()) _, err = s.Git.ApplyPatch(mergeCtx, repo.Name, patch.PatchContent, gitcmd.Ident{Name: meta.Author, Email: meta.Email}, gitcmd.Ident{Name: s.Cfg.CommitterName, Email: s.Cfg.CommitterEmail}) if err != nil { // Roll the status back so the patch stays mergeable. if rerr := s.DB.ReopenPatch(mergeCtx, patch.ID, db.NowISO()); rerr != nil { log.Printf("reopen patch %d after failed apply: %v", patch.ID, rerr) } if errors.Is(err, gitcmd.ErrConflict) { http.Error(w, "Patch does not apply", http.StatusConflict) return } http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } s.Patches.Delete(patch.ID) s.Git.InvalidateRefCache(repo.Name) redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10)) } func (s *Server) uploadPatchFile(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, patchCreateLimiter, false) || s.limited(w, r, uploadLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } num, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return } user := User(r) if (patch.AuthorID == nil || *patch.AuthorID != user.ID) && !user.IsAdmin { http.Error(w, "Forbidden", http.StatusForbidden) return } if patch.Status != "open" { http.Error(w, "Patch is not open", http.StatusBadRequest) return } if err := parseUploadForm(r, multipartMemory); err != nil { http.Error(w, "Patch file is required", http.StatusBadRequest) return } content, msg := s.readPatchUpload(r) if msg != "" { http.Error(w, msg, http.StatusBadRequest) return } meta := gitcmd.ExtractPatchMeta(content) if meta.Subject == "" || meta.Author == "" || meta.Email == "" || meta.Date == "" { http.Error(w, "Patch is missing required headers (Subject, From, Date)", http.StatusBadRequest) return } replaced, err := s.DB.ReplacePatchContent(r.Context(), patch.ID, content, meta.Author, meta.Email, newVersion(), db.NowISO()) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if !replaced { // The patch was closed or merged since the check above. http.Error(w, "Patch is not open", http.StatusConflict) return } s.Patches.Delete(patch.ID) s.runPatchCheck(r, repo.Name, patch.ID, content) redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10)) } func (s *Server) closePatch(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } num, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return } toggled, err := s.DB.TogglePatchClosed(r.Context(), patch.ID, db.NowISO()) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if !toggled { http.Error(w, "Patch is merged", http.StatusBadRequest) return } redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10)) } func (s *Server) deletePatch(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } num, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return } user := User(r) if !views.CanEdit(user, patch.AuthorID, patch.Status) { http.Error(w, "Forbidden", http.StatusForbidden) return } s.Patches.Delete(patch.ID) if err := s.DB.DeletePatch(r.Context(), patch.ID); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/patches") } func (s *Server) addPatchComment(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, commentLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } num, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return } target := "/" + repo.Name + "/patches/" + strconv.FormatInt(num, 10) user := User(r) // Only an admin may comment on a closed or merged patch. if patch.Status != "open" && !user.IsAdmin { redirectTo(w, r, target) return } body := r.FormValue("body") if tooLong(w, body, s.Cfg.MaxTextBodyBytes) { return } if strings.TrimSpace(body) == "" { redirectTo(w, r, target) return } if err := s.DB.AddPatchComment(r.Context(), patch.ID, &user.ID, strings.TrimSpace(body), db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } redirectTo(w, r, target) } func (s *Server) editPatchComment(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.PatchCommentAuth(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 !views.CanEdit(user, auth.AuthorID, auth.Status) { 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.UpdatePatchComment(r.Context(), commentID, strings.TrimSpace(body), db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/patches/"+chi.URLParam(r, "number")) } func (s *Server) reactPatch(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, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return } if !toggleReaction(w, r, func(commentID *int64) error { return s.DB.TogglePatchReaction(r.Context(), patch.ID, commentID, User(r).ID, emoji) }) { return } http.Redirect(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10), http.StatusSeeOther) } func (s *Server) editPatch(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, commentLimiter, false) { return } repo, ok := s.visibleRepo(w, r) if !ok { return } num, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return } user := User(r) if !views.CanEdit(user, patch.AuthorID, patch.Status) { http.Error(w, "Forbidden", http.StatusForbidden) return } title := r.FormValue("title") description := r.FormValue("edit_description") if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, description, s.Cfg.MaxTextBodyBytes) { return } if strings.TrimSpace(title) == "" { http.Error(w, "Title is required", http.StatusUnprocessableEntity) return } if err := s.DB.UpdatePatch(r.Context(), patch.ID, strings.TrimSpace(title), description, db.NowISO()); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10)) } func (s *Server) addPatchLabel(w http.ResponseWriter, r *http.Request) { repo, patch, num, ok := s.patchLabelTarget(w, r) if !ok { return } labelID, _ := leadingInt(r.FormValue("label_id")) target := "/" + repo.Name + "/patches/" + 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 { redirectTo(w, r, target) return } if err := s.DB.AddPatchLabel(r.Context(), patch.ID, label.ID); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } redirectTo(w, r, target) } func (s *Server) removePatchLabel(w http.ResponseWriter, r *http.Request) { repo, patch, num, ok := s.patchLabelTarget(w, r) if !ok { return } labelID, _ := leadingInt(r.FormValue("label_id")) if err := s.DB.RemovePatchLabel(r.Context(), patch.ID, labelID); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/patches/"+strconv.FormatInt(num, 10)) } // patchLabelTarget runs the shared checks of the label add and remove routes. func (s *Server) patchLabelTarget(w http.ResponseWriter, r *http.Request) (*db.Repo, *db.PatchRef, 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, _ := leadingInt(chi.URLParam(r, "number")) patch, ok := s.patchRef(w, r, repo.ID, num) if !ok { return nil, nil, 0, false } canManage := user.IsAdmin || (repo.AllowUserLabels && patch.AuthorID != nil && user.ID == *patch.AuthorID) if !canManage { http.Error(w, "Forbidden", http.StatusForbidden) return nil, nil, 0, false } return repo, patch, num, true }