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 } // 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 !mayRead(r) { s.loginRedirect(w, r) 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 } views := make([]proposalDocDiff, 0, len(docs)) 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. views = append(views, proposalDocDiff{ Path: d.Path, New: d.New, Diff: renderDocDiff(d.Base, d.Proposed), }) } owner := authn.PrincipalFromContext(r.Context()).IsOwner() 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 && p.State == core.StateOpen, Merged: p.State == core.StateMerged, Rejected: p.State == core.StateRejected, } 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" } }