package web import ( "context" "fmt" "net/http" "net/url" "strconv" "github.com/go-chi/chi/v5" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // proposalData is the review page's payload: the proposal, the per-document // prose diffs, and whether the viewer may act on it. type proposalData struct { Proposal service.Proposal // SpaceHref links back to the space at its approved head. SpaceHref string // StateBadge is the Bootstrap badge class for the proposal's state, so the // template does not branch on the state string. StateBadge string // Docs is one entry per document the proposal changes, in path order. Docs []proposalDocDiff // CanApprove reports whether the viewer is the owner and the proposal is // still open — the only case the approve/reject controls are shown. CanApprove bool // Approved / Rejected phrase the outcome for a terminal proposal. Merged bool Rejected bool // Lost are the review threads no block on this page claimed: an anchor that // did not resolve, or one on a document this proposal no longer changes. They // are rendered in their own area rather than dropped — a comment that // silently vanished would look like one that was never made — and never // against a block that is merely nearby. Lost []threadPanel // Unresolved is how many threads still await the owner. An open thread // suppresses policy auto-merge, so the count is why a proposal is still here. Unresolved int } // proposalDocDiff is one document's rendered diff on the review page. type proposalDocDiff struct { Path string New bool Diff diffView } // proposalIDFrom parses the {id} path parameter. A non-numeric or non-positive // id is not a proposal — "/p/" is the proposal namespace, and a document that // happens to sit under a "p/" path is addressed through the document route, not // here — so it is a 404 rather than a 400. func proposalIDFrom(r *http.Request) (int, bool) { id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil || id <= 0 { return 0, false } return id, true } // handleProposal renders the review page for one proposal: its metadata, its // status, and a prose diff of every document it changes. // // The diff reads the proposal branch, which the normal read plane refuses — so // it goes through service.ProposalDiff, which resolves the branch tip to a sha // and reads content the review is entitled to see. A proposal whose space does // not match the URL is a 404: the id is global, but the link names its space, // and answering for the wrong space would let one space's URL surface another's // proposal. func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) { if !s.allowRead(w, r, formatHTML) { return } ref, err := spaceRefFrom(r) if err != nil { s.fail(w, r, err) return } id, ok := proposalIDFrom(r) if !ok { s.renderError(w, r, http.StatusNotFound, "no such proposal") return } p, err := s.reader.GetProposal(r.Context(), id) if err != nil { s.fail(w, r, err) return } if p.Space != ref { s.renderError(w, r, http.StatusNotFound, "no such proposal in this space") return } docs, err := s.reader.ProposalDiff(r.Context(), p) if err != nil { s.fail(w, r, err) return } principal := authn.PrincipalFromContext(r.Context()) threads, err := s.reader.Threads(r.Context(), principal, id) if err != nil { s.fail(w, r, err) return } // Anchor fit is a property of the revision on screen, so it is resolved // against the documents this page is about to render and nowhere else. Doing // it here rather than in each renderer also means the state a thread reports // and the blocks the diff draws describe the same bytes. threads = service.AnchorThreads(threads, docs) byDoc := make(map[string][]service.Thread, len(docs)) for _, t := range threads { byDoc[t.DocPath] = append(byDoc[t.DocPath], t) } // The controls follow the service's two authorities: opening and resolving // are the owner's, replying is any reader's. Both are offered only while the // proposal is open — a merged or rejected proposal's conversation is history, // and there is no auto-merge left for a thread to gate. open := p.State == core.StateOpen owner := principal.IsOwner() controls := reviewControls{ Owner: owner && open, Reply: open, ActionBase: proposalHref(p), } views := make([]proposalDocDiff, 0, len(docs)) var lost []service.Thread for _, d := range docs { // A new document diffs against nothing, which renders as an all-inserted // block set — the same renderer, so the page has one code path. view := renderDocDiff(docDiff{ DocID: docIDFor(d.Path, d.Proposed), Path: d.Path, Base: d.Base, Proposed: d.Proposed, Threads: byDoc[d.Path], Controls: controls, }) lost = append(lost, view.Unplaced...) delete(byDoc, d.Path) views = append(views, proposalDocDiff{Path: d.Path, New: d.New, Diff: view}) } // Whatever is left belongs to a document this proposal no longer changes — // the agent reverted it — so no renderer ever saw those threads. They are as // lost as an unresolved anchor, and just as visible. for _, ts := range byDoc { lost = append(lost, ts...) } vd := s.chrome(r) vd.Title = fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title) vd.Data = proposalData{ Proposal: p, SpaceHref: "/" + ref.String(), StateBadge: stateBadge(p.State), Docs: views, CanApprove: owner && open, Merged: p.State == core.StateMerged, Rejected: p.State == core.StateRejected, Lost: lostPanels(lost, controls), Unresolved: unresolvedThreads(threads), } s.render(w, http.StatusOK, "proposal", vd) } // handleProposalApprove merges a proposal on the owner's approval, then redirects // back to the proposal page so a reload does not re-submit. func (s *Server) handleProposalApprove(w http.ResponseWriter, r *http.Request) { s.actOnProposal(w, r, s.reader.Approve) } // handleProposalReject resolves a proposal to rejected, then redirects back. func (s *Server) handleProposalReject(w http.ResponseWriter, r *http.Request) { s.actOnProposal(w, r, s.reader.Reject) } // actOnProposal is the shared approve/reject path: the owner-only gate, the // cross-site guard, the action, and the post-redirect-get back to the page. // // Only the owner may approve or reject — that is the one authority the whole // authorization model turns on, and an agent, though authenticated, has it no // more than an anonymous viewer. The cross-site guard refuses a state change // whose Origin is not this instance, which is the CSRF defense a form post needs // when the session cookie is meta's and this service cannot set its SameSite. func (s *Server) actOnProposal(w http.ResponseWriter, r *http.Request, act func(context.Context, core.SpaceRef, int) (service.Proposal, error)) { if !authn.PrincipalFromContext(r.Context()).IsOwner() { s.renderError(w, r, http.StatusForbidden, "only the instance owner may approve or reject a proposal") return } if !s.sameOrigin(r) { s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site") return } ref, err := spaceRefFrom(r) if err != nil { s.fail(w, r, err) return } id, ok := proposalIDFrom(r) if !ok { s.renderError(w, r, http.StatusNotFound, "no such proposal") return } if _, err := act(r.Context(), ref, id); err != nil { s.fail(w, r, err) return } http.Redirect(w, r, fmt.Sprintf("/%s/p/%d", ref, id), http.StatusSeeOther) } // sameOrigin reports whether a state-changing request came from this site. It // checks the Origin header — which browsers send on every form POST — and falls // back to Referer, refusing a request that carries neither. A cross-site forgery // carries the attacker's origin and fails; this instance's own form carries its // own and passes. func (s *Server) sameOrigin(r *http.Request) bool { claimed := r.Header.Get("Origin") if claimed == "" { claimed = r.Header.Get("Referer") } if claimed == "" { return false } got, err := url.Parse(claimed) if err != nil || got.Host == "" { return false } want, err := url.Parse(s.origin) if err != nil { return false } return got.Scheme == want.Scheme && got.Host == want.Host } // stateBadge maps a proposal state onto the Bootstrap badge class the template // tags it with, so the presentation choice lives in one place. func stateBadge(state core.ProposalState) string { switch state { case core.StateMerged: return "badge-success" case core.StateRejected: return "badge-danger" default: return "badge-primary" } }