package api import ( "io" "net/http" "net/url" "strconv" "strings" "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" ) // handlePut is the write plane's one handler: PUT a whole document to open or // extend a proposal. // // It parses the request into a service.ProposeRequest and calls through — the // document path from the URL, the base from If-Match, the optional target // proposal from X-Proposal, the title/rationale/message from the query string, // and the document from the body. The status is 201 when a new proposal was // opened and 200 when documents were added to an existing one; the merged field // says whether policy landed it immediately. func (s *Server) handlePut(w http.ResponseWriter, r *http.Request) { ref := core.SpaceRef{Owner: chi.URLParam(r, "owner"), Name: chi.URLParam(r, "space")} docPath, ok := unescapePath(chi.URLParam(r, "*")) if !ok || docPath == "" { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "the document path after /docs/ is missing or malformed", }) return } base := strings.TrimSpace(r.Header.Get("If-Match")) if base == "" { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "If-Match is required: send the approved-head revision you read at, so the " + "proposal has a base and a concurrent change cannot be clobbered", }) return } proposalID, ok := parseProposalHeader(r.Header.Get("X-Proposal")) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "X-Proposal must be a positive proposal id; omit it to open a new proposal", }) return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) if err != nil { // MaxBytesReader signals an over-limit body through a read error; there // is no way to tell it from a truncated client here, so both are 413. writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{ "error": "the document body could not be read or exceeds the size limit", }) return } // The acting agent is resolved from the bearer token by the middleware. // service.Propose refuses a non-agent, so an anonymous caller is a 403 there // rather than a check duplicated here. principal := authn.PrincipalFromContext(r.Context()) res, err := s.writer.Propose(r.Context(), service.ProposeRequest{ Space: ref, Principal: principal, ProposalID: proposalID, Title: strings.TrimSpace(r.URL.Query().Get("title")), Rationale: strings.TrimSpace(r.URL.Query().Get("rationale")), IfMatch: base, Message: strings.TrimSpace(r.URL.Query().Get("message")), Writes: []service.DocumentWrite{{Path: docPath, Content: body}}, }) if err != nil { writeError(w, err) return } status := http.StatusOK if proposalID == 0 { status = http.StatusCreated } writeJSON(w, status, proposeResponse{ Proposal: res.Proposal.ID, URL: res.URL, Merged: res.Merged, State: string(res.Proposal.State), Branch: res.Proposal.Branch, BaseRev: res.Proposal.BaseRev, }) } // parseProposalHeader reads the optional X-Proposal header. An empty header // means "open a new proposal" and is valid; a present value must be a positive // integer. It returns the id and whether the header was well-formed. func parseProposalHeader(v string) (int, bool) { v = strings.TrimSpace(v) if v == "" { return 0, true } id, err := strconv.Atoi(v) if err != nil || id <= 0 { return 0, false } return id, true } // unescapePath decodes a chi trailing wildcard back into a document path, // per segment. // // chi routes on the raw (percent-encoded) path when the request had one, so a // document whose name carries a space or a Cyrillic letter arrives encoded. // Decoding per segment is deliberate: a %2F inside a segment is a literal slash // in a name, not a path separator, and joining decoded segments with "/" keeps // it from becoming one. It mirrors web/'s unescapePath so the read and write // surfaces address a document by exactly the same path grammar. func unescapePath(raw string) (string, bool) { if raw == "" { return "", true } segs := strings.Split(raw, "/") for i, seg := range segs { dec, err := url.PathUnescape(seg) if err != nil { return "", false } segs[i] = dec } return strings.Join(segs, "/"), true } // compile-time assertion that the production service satisfies the write this // surface needs. var _ Writer = (*service.Service)(nil)