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.view(r, 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.chromeSvc.SelfOrigin())
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"
}
}