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"
}
}