| 1 |
|
package api |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"io" |
| 5 |
|
"net/http" |
| 6 |
|
"net/url" |
| 7 |
|
"strconv" |
| 8 |
|
"strings" |
| 9 |
|
|
| 10 |
|
"github.com/go-chi/chi/v5" |
| 11 |
|
|
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 15 |
|
) |
| 16 |
|
|
| 17 |
|
// handlePut is the write plane's one handler: PUT a whole document to open or |
| 18 |
|
// extend a proposal. |
| 19 |
|
// |
| 20 |
|
// It parses the request into a service.ProposeRequest and calls through — the |
| 21 |
|
// document path from the URL, the base from If-Match, the optional target |
| 22 |
|
// proposal from X-Proposal, the title/rationale/message from the query string, |
| 23 |
|
// and the document from the body. The status is 201 when a new proposal was |
| 24 |
|
// opened and 200 when documents were added to an existing one; the merged field |
| 25 |
|
// says whether policy landed it immediately. |
| 26 |
13 |
func (s *Server) handlePut(w http.ResponseWriter, r *http.Request) { |
| 27 |
13 |
ref := core.SpaceRef{Owner: chi.URLParam(r, "owner"), Name: chi.URLParam(r, "space")} |
| 28 |
13 |
|
| 29 |
13 |
docPath, ok := unescapePath(chi.URLParam(r, "*")) |
| 30 |
13 |
if !ok || docPath == "" { |
| 31 |
0 |
writeJSON(w, http.StatusBadRequest, map[string]string{ |
| 32 |
0 |
"error": "the document path after /docs/ is missing or malformed", |
| 33 |
0 |
}) |
| 34 |
0 |
return |
| 35 |
0 |
} |
| 36 |
|
|
| 37 |
13 |
base := strings.TrimSpace(r.Header.Get("If-Match")) |
| 38 |
13 |
if base == "" { |
| 39 |
1 |
writeJSON(w, http.StatusBadRequest, map[string]string{ |
| 40 |
1 |
"error": "If-Match is required: send the approved-head revision you read at, so the " + |
| 41 |
1 |
"proposal has a base and a concurrent change cannot be clobbered", |
| 42 |
1 |
}) |
| 43 |
1 |
return |
| 44 |
1 |
} |
| 45 |
|
|
| 46 |
12 |
proposalID, ok := parseProposalHeader(r.Header.Get("X-Proposal")) |
| 47 |
12 |
if !ok { |
| 48 |
1 |
writeJSON(w, http.StatusBadRequest, map[string]string{ |
| 49 |
1 |
"error": "X-Proposal must be a positive proposal id; omit it to open a new proposal", |
| 50 |
1 |
}) |
| 51 |
1 |
return |
| 52 |
1 |
} |
| 53 |
|
|
| 54 |
11 |
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) |
| 55 |
11 |
if err != nil { |
| 56 |
0 |
// MaxBytesReader signals an over-limit body through a read error; there |
| 57 |
0 |
// is no way to tell it from a truncated client here, so both are 413. |
| 58 |
0 |
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{ |
| 59 |
0 |
"error": "the document body could not be read or exceeds the size limit", |
| 60 |
0 |
}) |
| 61 |
0 |
return |
| 62 |
0 |
} |
| 63 |
|
|
| 64 |
|
// The acting agent is resolved from the bearer token by the middleware. |
| 65 |
|
// service.Propose refuses a non-agent, so an anonymous caller is a 403 there |
| 66 |
|
// rather than a check duplicated here. |
| 67 |
11 |
principal := authn.PrincipalFromContext(r.Context()) |
| 68 |
11 |
|
| 69 |
11 |
res, err := s.writer.Propose(r.Context(), service.ProposeRequest{ |
| 70 |
11 |
Space: ref, |
| 71 |
11 |
Principal: principal, |
| 72 |
11 |
ProposalID: proposalID, |
| 73 |
11 |
Title: strings.TrimSpace(r.URL.Query().Get("title")), |
| 74 |
11 |
Rationale: strings.TrimSpace(r.URL.Query().Get("rationale")), |
| 75 |
11 |
IfMatch: base, |
| 76 |
11 |
Message: strings.TrimSpace(r.URL.Query().Get("message")), |
| 77 |
11 |
Writes: []service.DocumentWrite{{Path: docPath, Content: body}}, |
| 78 |
11 |
}) |
| 79 |
11 |
if err != nil { |
| 80 |
7 |
writeError(w, err) |
| 81 |
7 |
return |
| 82 |
7 |
} |
| 83 |
|
|
| 84 |
4 |
status := http.StatusOK |
| 85 |
4 |
if proposalID == 0 { |
| 86 |
3 |
status = http.StatusCreated |
| 87 |
3 |
} |
| 88 |
4 |
writeJSON(w, status, proposeResponse{ |
| 89 |
4 |
Proposal: res.Proposal.ID, |
| 90 |
4 |
URL: res.URL, |
| 91 |
4 |
Merged: res.Merged, |
| 92 |
4 |
State: string(res.Proposal.State), |
| 93 |
4 |
Branch: res.Proposal.Branch, |
| 94 |
4 |
BaseRev: res.Proposal.BaseRev, |
| 95 |
4 |
}) |
| 96 |
|
} |
| 97 |
|
|
| 98 |
|
// parseProposalHeader reads the optional X-Proposal header. An empty header |
| 99 |
|
// means "open a new proposal" and is valid; a present value must be a positive |
| 100 |
|
// integer. It returns the id and whether the header was well-formed. |
| 101 |
12 |
func parseProposalHeader(v string) (int, bool) { |
| 102 |
12 |
v = strings.TrimSpace(v) |
| 103 |
12 |
if v == "" { |
| 104 |
10 |
return 0, true |
| 105 |
10 |
} |
| 106 |
2 |
id, err := strconv.Atoi(v) |
| 107 |
2 |
if err != nil || id <= 0 { |
| 108 |
1 |
return 0, false |
| 109 |
1 |
} |
| 110 |
1 |
return id, true |
| 111 |
|
} |
| 112 |
|
|
| 113 |
|
// unescapePath decodes a chi trailing wildcard back into a document path, |
| 114 |
|
// per segment. |
| 115 |
|
// |
| 116 |
|
// chi routes on the raw (percent-encoded) path when the request had one, so a |
| 117 |
|
// document whose name carries a space or a Cyrillic letter arrives encoded. |
| 118 |
|
// Decoding per segment is deliberate: a %2F inside a segment is a literal slash |
| 119 |
|
// in a name, not a path separator, and joining decoded segments with "/" keeps |
| 120 |
|
// it from becoming one. It mirrors web/'s unescapePath so the read and write |
| 121 |
|
// surfaces address a document by exactly the same path grammar. |
| 122 |
13 |
func unescapePath(raw string) (string, bool) { |
| 123 |
13 |
if raw == "" { |
| 124 |
0 |
return "", true |
| 125 |
0 |
} |
| 126 |
13 |
segs := strings.Split(raw, "/") |
| 127 |
26 |
for i, seg := range segs { |
| 128 |
26 |
dec, err := url.PathUnescape(seg) |
| 129 |
26 |
if err != nil { |
| 130 |
0 |
return "", false |
| 131 |
0 |
} |
| 132 |
26 |
segs[i] = dec |
| 133 |
|
} |
| 134 |
13 |
return strings.Join(segs, "/"), true |
| 135 |
|
} |
| 136 |
|
|
| 137 |
|
// compile-time assertion that the production service satisfies the write this |
| 138 |
|
// surface needs. |
| 139 |
|
var _ Writer = (*service.Service)(nil) |