Go · 8778 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 "github.com/tenseleyFlow/shithub/internal/actions/dispatch"
20 actionsevent "github.com/tenseleyFlow/shithub/internal/actions/event"
21 actionspolicy "github.com/tenseleyFlow/shithub/internal/actions/policy"
22 "github.com/tenseleyFlow/shithub/internal/actions/trigger"
23 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
24 "github.com/tenseleyFlow/shithub/internal/auth/policy"
25 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
26 "github.com/tenseleyFlow/shithub/internal/web/middleware"
27 )
28
29 // dispatchRequest is the JSON body shape the dispatch endpoint
30 // accepts. Both fields are optional:
31 //
32 // - ref defaults to the repo's default branch (e.g. "trunk").
33 // Accepts short ("trunk") or fully-qualified ("refs/heads/trunk")
34 // forms; we resolve to a SHA via git.
35 // - inputs is the workflow_dispatch.inputs map. Values are
36 // stringified to match GHA semantics (booleans arrive as
37 // "true"/"false" strings).
38 type dispatchRequest struct {
39 Ref string `json:"ref,omitempty"`
40 Inputs map[string]string `json:"inputs,omitempty"`
41 }
42
43 // dispatchMaxBody bounds the request body to keep handler memory
44 // predictable. Inputs are key/value strings; 64 KiB is well above
45 // any realistic dispatch body.
46 const dispatchMaxBody = 64 * 1024
47
48 // repoActionsDispatch implements
49 //
50 // POST /{owner}/{repo}/actions/workflows/{file}/dispatches
51 //
52 // 204 on success; the trigger pipeline runs synchronously here
53 // because the workflow file is already known (no discovery needed),
54 // so latency is the cost of one parse + one Enqueue.
55 //
56 // Auth: requires repo write access (policy.ActionRepoWrite). Anyone
57 // who can push to the repo can dispatch a workflow on it; same
58 // trust boundary as the runner that picks the resulting run up.
59 func (h *Handlers) repoActionsDispatch(w http.ResponseWriter, r *http.Request) {
60 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoWrite)
61 if !ok {
62 return
63 }
64
65 // {file} is URL-escaped (slashes in chi route params don't survive
66 // without the * splat pattern; we use Path-Value-style escaping).
67 file := chi.URLParam(r, "file")
68 if file == "" {
69 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "missing workflow file")
70 return
71 }
72 // Workflows live under .shithub/workflows/. Authors can pass
73 // either "ci.yml" (basename) or ".shithub/workflows/ci.yml" (full
74 // path). Normalize so the trigger pipeline always sees the full path.
75 file, err := dispatch.NormalizeFilePath(file)
76 if err != nil {
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 := dispatch.NormalizeInputs(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 decision, err := actionspolicy.EvaluateTrigger(r.Context(), actionspolicy.Deps{Pool: h.d.Pool}, actionspolicy.TriggerRequest{
158 Repo: row,
159 EventKind: string(trigger.EventWorkflowDispatch),
160 ActorUserID: actorID,
161 })
162 if err != nil || !decision.Allow {
163 h.d.Logger.WarnContext(r.Context(), "actions dispatch: blocked by actions policy",
164 "repo_id", row.ID, "workflow_file", file, "reason", decision.Reason, "error", err)
165 h.d.Render.HTTPError(w, r, http.StatusForbidden, "Actions are not allowed to run for this repository.")
166 return
167 }
168 if _, err := trigger.Enqueue(r.Context(), trigger.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, trigger.EnqueueParams{
169 RepoID: row.ID,
170 WorkflowFile: file,
171 HeadSHA: headSHA,
172 HeadRef: "refs/heads/" + branch,
173 EventKind: trigger.EventWorkflowDispatch,
174 EventPayload: payload,
175 ActorUserID: actorID,
176 TriggerEventID: triggerID,
177 Workflow: wf,
178 }); err != nil {
179 h.d.Logger.ErrorContext(r.Context(), "actions dispatch: enqueue", "error", err)
180 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
181 return
182 }
183 if formPost {
184 basePath := "/" + owner.Username + "/" + row.Name + "/actions"
185 redirectTo := actionsWorkflowRoutePath(basePath, file)
186 q := url.Values{"query": []string{"event:workflow_dispatch"}}
187 if redirectTo == "" {
188 redirectTo = basePath
189 q.Set("workflow", file)
190 }
191 redirectTo = pathWithQuery(redirectTo, q)
192 http.Redirect(w, r, redirectTo, http.StatusSeeOther)
193 return
194 }
195 w.WriteHeader(http.StatusNoContent)
196 }
197
198 func (h *Handlers) parseDispatchRequest(w http.ResponseWriter, r *http.Request) (dispatchRequest, bool, bool) {
199 if mediaType := dispatchFormMediaType(r); mediaType != "" {
200 r.Body = http.MaxBytesReader(w, r.Body, dispatchMaxBody)
201 if err := r.ParseForm(); err != nil {
202 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid form body: "+err.Error())
203 return dispatchRequest{}, true, false
204 }
205 return dispatchRequest{
206 Ref: strings.TrimSpace(r.PostFormValue("ref")),
207 Inputs: dispatch.InputsFromForm(r.PostForm),
208 }, true, true
209 }
210
211 body, err := io.ReadAll(io.LimitReader(r.Body, dispatchMaxBody+1))
212 if err != nil {
213 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "read body: "+err.Error())
214 return dispatchRequest{}, false, false
215 }
216 if len(body) > dispatchMaxBody {
217 h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "body exceeds 64 KiB")
218 return dispatchRequest{}, false, false
219 }
220 var req dispatchRequest
221 if len(body) > 0 {
222 if err := json.Unmarshal(body, &req); err != nil {
223 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid JSON body: "+err.Error())
224 return dispatchRequest{}, false, false
225 }
226 }
227 return req, false, true
228 }
229
230 func dispatchFormMediaType(r *http.Request) string {
231 mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
232 if err != nil {
233 return ""
234 }
235 switch mediaType {
236 case "application/x-www-form-urlencoded":
237 return mediaType
238 default:
239 return ""
240 }
241 }
242
243 // randHex returns 2*n hex chars from crypto/rand. 8 bytes (16 hex
244 // chars) is plenty of entropy for a dispatch request id.
245 func randHex(n int) (string, error) {
246 b := make([]byte, n)
247 if _, err := rand.Read(b); err != nil {
248 return "", err
249 }
250 return hex.EncodeToString(b), nil
251 }
252