Go · 10356 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "crypto/rand"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "mime"
13 "net/http"
14 "net/url"
15 "strings"
16
17 "github.com/go-chi/chi/v5"
18
19 actionsevent "github.com/tenseleyFlow/shithub/internal/actions/event"
20 "github.com/tenseleyFlow/shithub/internal/actions/trigger"
21 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
22 "github.com/tenseleyFlow/shithub/internal/auth/policy"
23 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
24 "github.com/tenseleyFlow/shithub/internal/web/middleware"
25 )
26
27 // dispatchRequest is the JSON body shape the dispatch endpoint
28 // accepts. Both fields are optional:
29 //
30 // - ref defaults to the repo's default branch (e.g. "trunk").
31 // Accepts short ("trunk") or fully-qualified ("refs/heads/trunk")
32 // forms; we resolve to a SHA via git.
33 // - inputs is the workflow_dispatch.inputs map. Values are
34 // stringified to match GHA semantics (booleans arrive as
35 // "true"/"false" strings).
36 type dispatchRequest struct {
37 Ref string `json:"ref,omitempty"`
38 Inputs map[string]string `json:"inputs,omitempty"`
39 }
40
41 // dispatchMaxBody bounds the request body to keep handler memory
42 // predictable. Inputs are key/value strings; 64 KiB is well above
43 // any realistic dispatch body.
44 const dispatchMaxBody = 64 * 1024
45
46 // repoActionsDispatch implements
47 //
48 // POST /{owner}/{repo}/actions/workflows/{file}/dispatches
49 //
50 // 204 on success; the trigger pipeline runs synchronously here
51 // because the workflow file is already known (no discovery needed),
52 // so latency is the cost of one parse + one Enqueue.
53 //
54 // Auth: requires repo write access (policy.ActionRepoWrite). Anyone
55 // who can push to the repo can dispatch a workflow on it; same
56 // trust boundary as the runner that picks the resulting run up.
57 func (h *Handlers) repoActionsDispatch(w http.ResponseWriter, r *http.Request) {
58 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoWrite)
59 if !ok {
60 return
61 }
62
63 // {file} is URL-escaped (slashes in chi route params don't survive
64 // without the * splat pattern; we use Path-Value-style escaping).
65 file := chi.URLParam(r, "file")
66 if file == "" {
67 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "missing workflow file")
68 return
69 }
70 // Workflows live under .shithub/workflows/. Authors can pass
71 // either "ci.yml" (basename) or ".shithub/workflows/ci.yml" (full
72 // path). Normalize so the trigger pipeline always sees the full path.
73 if !strings.HasPrefix(file, ".shithub/workflows/") {
74 file = ".shithub/workflows/" + file
75 }
76 if strings.Contains(file, "..") || !validWorkflowName(file) {
77 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid workflow file path")
78 return
79 }
80
81 req, formPost, ok := h.parseDispatchRequest(w, r)
82 if !ok {
83 return
84 }
85
86 ref := req.Ref
87 if ref == "" {
88 ref = row.DefaultBranch
89 }
90 // Strip refs/heads/ prefix if present so we match git's branch
91 // resolution shape.
92 branch := strings.TrimPrefix(ref, "refs/heads/")
93
94 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
95 if err != nil {
96 h.d.Logger.ErrorContext(r.Context(), "actions dispatch: repo path", "error", err)
97 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
98 return
99 }
100 headSHA, err := repogit.ResolveRefOID(r.Context(), gitDir, branch)
101 if err != nil {
102 if errors.Is(err, repogit.ErrRefNotFound) {
103 h.d.Render.HTTPError(w, r, http.StatusNotFound, "ref "+branch+" not found")
104 return
105 }
106 h.d.Logger.WarnContext(r.Context(), "actions dispatch: resolve ref", "error", err)
107 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "could not resolve ref")
108 return
109 }
110
111 // Read + parse the specific workflow at this SHA. Saves a tree walk
112 // since the dispatch knows exactly which file to run.
113 bytes, err := repogit.ReadBlobBytes(r.Context(), gitDir, headSHA, file, int64(workflow.MaxWorkflowFileBytes))
114 if err != nil {
115 h.d.Render.HTTPError(w, r, http.StatusNotFound,
116 fmt.Sprintf("workflow file %q not found at ref %s", file, branch))
117 return
118 }
119 wf, diags, err := workflow.Parse(bytes)
120 if err != nil {
121 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "workflow parse: "+err.Error())
122 return
123 }
124 for _, d := range diags {
125 if d.Severity == workflow.Error {
126 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "workflow has Error diagnostics: "+d.String())
127 return
128 }
129 }
130 if wf.On.WorkflowDispatch == nil {
131 h.d.Render.HTTPError(w, r, http.StatusBadRequest,
132 "workflow does not declare on.workflow_dispatch")
133 return
134 }
135 inputs, err := normalizeDispatchInputs(req.Inputs, wf.On.WorkflowDispatch.Inputs)
136 if err != nil {
137 h.d.Render.HTTPError(w, r, http.StatusBadRequest, err.Error())
138 return
139 }
140
141 // Each dispatch click produces a fresh trigger_event_id with a
142 // unique random suffix — the same workflow file at the same SHA
143 // can be dispatched multiple times by a human and each fires.
144 // (Compare the push trigger, which dedups on push_event_id.)
145 requestID, err := randHex(8)
146 if err != nil {
147 h.d.Logger.ErrorContext(r.Context(), "actions dispatch: rand", "error", err)
148 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
149 return
150 }
151 triggerID := fmt.Sprintf("dispatch:%s:%s:%s", file, headSHA, requestID)
152
153 viewer := middleware.CurrentUserFromContext(r.Context())
154 actorID := viewer.ID // 0 if anonymous, but RequireUser is in front of this route
155
156 payload := actionsevent.WorkflowDispatch(inputs)
157 if _, err := trigger.Enqueue(r.Context(), trigger.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, trigger.EnqueueParams{
158 RepoID: row.ID,
159 WorkflowFile: file,
160 HeadSHA: headSHA,
161 HeadRef: "refs/heads/" + branch,
162 EventKind: trigger.EventWorkflowDispatch,
163 EventPayload: payload,
164 ActorUserID: actorID,
165 TriggerEventID: triggerID,
166 Workflow: wf,
167 }); err != nil {
168 h.d.Logger.ErrorContext(r.Context(), "actions dispatch: enqueue", "error", err)
169 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
170 return
171 }
172 if formPost {
173 redirectTo := "/" + owner.Username + "/" + row.Name + "/actions?workflow=" + url.QueryEscape(file) + "&event=workflow_dispatch"
174 http.Redirect(w, r, redirectTo, http.StatusSeeOther)
175 return
176 }
177 w.WriteHeader(http.StatusNoContent)
178 }
179
180 func (h *Handlers) parseDispatchRequest(w http.ResponseWriter, r *http.Request) (dispatchRequest, bool, bool) {
181 if mediaType := dispatchFormMediaType(r); mediaType != "" {
182 r.Body = http.MaxBytesReader(w, r.Body, dispatchMaxBody)
183 if err := r.ParseForm(); err != nil {
184 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid form body: "+err.Error())
185 return dispatchRequest{}, true, false
186 }
187 return dispatchRequest{
188 Ref: strings.TrimSpace(r.PostFormValue("ref")),
189 Inputs: dispatchInputsFromForm(r.PostForm),
190 }, true, true
191 }
192
193 body, err := io.ReadAll(io.LimitReader(r.Body, dispatchMaxBody+1))
194 if err != nil {
195 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "read body: "+err.Error())
196 return dispatchRequest{}, false, false
197 }
198 if len(body) > dispatchMaxBody {
199 h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "body exceeds 64 KiB")
200 return dispatchRequest{}, false, false
201 }
202 var req dispatchRequest
203 if len(body) > 0 {
204 if err := json.Unmarshal(body, &req); err != nil {
205 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid JSON body: "+err.Error())
206 return dispatchRequest{}, false, false
207 }
208 }
209 return req, false, true
210 }
211
212 func dispatchFormMediaType(r *http.Request) string {
213 mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
214 if err != nil {
215 return ""
216 }
217 switch mediaType {
218 case "application/x-www-form-urlencoded":
219 return mediaType
220 default:
221 return ""
222 }
223 }
224
225 func dispatchInputsFromForm(values url.Values) map[string]string {
226 inputs := make(map[string]string)
227 for key, vals := range values {
228 name, ok := strings.CutPrefix(key, "inputs.")
229 if !ok || name == "" || len(vals) == 0 {
230 continue
231 }
232 inputs[name] = vals[len(vals)-1]
233 }
234 if len(inputs) == 0 {
235 return nil
236 }
237 return inputs
238 }
239
240 func normalizeDispatchInputs(raw map[string]string, specs []workflow.DispatchInput) (map[string]string, error) {
241 if raw == nil {
242 raw = map[string]string{}
243 }
244 known := make(map[string]workflow.DispatchInput, len(specs))
245 for _, spec := range specs {
246 known[spec.Name] = spec
247 }
248 for name := range raw {
249 if _, ok := known[name]; !ok {
250 return nil, fmt.Errorf("unknown workflow_dispatch input %q", name)
251 }
252 }
253
254 out := make(map[string]string, len(specs))
255 for _, spec := range specs {
256 value, provided := raw[spec.Name]
257 if !provided || value == "" {
258 value = spec.Default
259 }
260 if spec.Type == "boolean" && !provided && value == "" {
261 value = "false"
262 }
263 if spec.Required && spec.Type != "boolean" && strings.TrimSpace(value) == "" {
264 return nil, fmt.Errorf("workflow_dispatch input %q is required", spec.Name)
265 }
266 switch spec.Type {
267 case "boolean":
268 if value != "true" && value != "false" {
269 return nil, fmt.Errorf("workflow_dispatch input %q must be true or false", spec.Name)
270 }
271 case "choice":
272 if value != "" && !dispatchChoiceAllowed(value, spec.Options) {
273 return nil, fmt.Errorf("workflow_dispatch input %q must be one of the declared options", spec.Name)
274 }
275 }
276 if value != "" || spec.Type == "boolean" {
277 out[spec.Name] = value
278 }
279 }
280 if len(out) == 0 {
281 return nil, nil
282 }
283 return out, nil
284 }
285
286 func dispatchChoiceAllowed(value string, options []string) bool {
287 for _, option := range options {
288 if value == option {
289 return true
290 }
291 }
292 return false
293 }
294
295 // validWorkflowName guards against URL parameter shenanigans by
296 // requiring the resolved file path to look like
297 // `.shithub/workflows/<basename>.{yml,yaml}` with no path traversal.
298 func validWorkflowName(file string) bool {
299 if !strings.HasPrefix(file, ".shithub/workflows/") {
300 return false
301 }
302 rest := strings.TrimPrefix(file, ".shithub/workflows/")
303 if rest == "" || strings.ContainsAny(rest, "/\x00") {
304 return false
305 }
306 return strings.HasSuffix(rest, ".yml") || strings.HasSuffix(rest, ".yaml")
307 }
308
309 // randHex returns 2*n hex chars from crypto/rand. 8 bytes (16 hex
310 // chars) is plenty of entropy for a dispatch request id.
311 func randHex(n int) (string, error) {
312 b := make([]byte, n)
313 if _, err := rand.Read(b); err != nil {
314 return "", err
315 }
316 return hex.EncodeToString(b), nil
317 }
318