Go · 7090 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 "net/http"
13 "strings"
14
15 "github.com/go-chi/chi/v5"
16
17 actionsevent "github.com/tenseleyFlow/shithub/internal/actions/event"
18 "github.com/tenseleyFlow/shithub/internal/actions/trigger"
19 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
20 "github.com/tenseleyFlow/shithub/internal/auth/policy"
21 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
22 "github.com/tenseleyFlow/shithub/internal/web/middleware"
23 )
24
25 // dispatchRequest is the JSON body shape the dispatch endpoint
26 // accepts. Both fields are optional:
27 //
28 // - ref defaults to the repo's default branch (e.g. "trunk").
29 // Accepts short ("trunk") or fully-qualified ("refs/heads/trunk")
30 // forms; we resolve to a SHA via git.
31 // - inputs is the workflow_dispatch.inputs map. Values are
32 // stringified to match GHA semantics (booleans arrive as
33 // "true"/"false" strings).
34 type dispatchRequest struct {
35 Ref string `json:"ref,omitempty"`
36 Inputs map[string]string `json:"inputs,omitempty"`
37 }
38
39 // dispatchMaxBody bounds the request body to keep handler memory
40 // predictable. Inputs are key/value strings; 64 KiB is well above
41 // any realistic dispatch body.
42 const dispatchMaxBody = 64 * 1024
43
44 // repoActionsDispatch implements
45 //
46 // POST /{owner}/{repo}/actions/workflows/{file}/dispatches
47 //
48 // 204 on success; the trigger pipeline runs synchronously here
49 // because the workflow file is already known (no discovery needed),
50 // so latency is the cost of one parse + one Enqueue.
51 //
52 // Auth: requires repo write access (policy.ActionRepoWrite). Anyone
53 // who can push to the repo can dispatch a workflow on it; same
54 // trust boundary as the runner that picks the resulting run up.
55 func (h *Handlers) repoActionsDispatch(w http.ResponseWriter, r *http.Request) {
56 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoWrite)
57 if !ok {
58 return
59 }
60
61 // {file} is URL-escaped (slashes in chi route params don't survive
62 // without the * splat pattern; we use Path-Value-style escaping).
63 file := chi.URLParam(r, "file")
64 if file == "" {
65 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "missing workflow file")
66 return
67 }
68 // Workflows live under .shithub/workflows/. Authors can pass
69 // either "ci.yml" (basename) or ".shithub/workflows/ci.yml" (full
70 // path). Normalize so the trigger pipeline always sees the full path.
71 if !strings.HasPrefix(file, ".shithub/workflows/") {
72 file = ".shithub/workflows/" + file
73 }
74 if strings.Contains(file, "..") || !validWorkflowName(file) {
75 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid workflow file path")
76 return
77 }
78
79 body, err := io.ReadAll(io.LimitReader(r.Body, dispatchMaxBody+1))
80 if err != nil {
81 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "read body: "+err.Error())
82 return
83 }
84 if len(body) > dispatchMaxBody {
85 h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "body exceeds 64 KiB")
86 return
87 }
88 var req dispatchRequest
89 if len(body) > 0 {
90 if err := json.Unmarshal(body, &req); err != nil {
91 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid JSON body: "+err.Error())
92 return
93 }
94 }
95
96 ref := req.Ref
97 if ref == "" {
98 ref = row.DefaultBranch
99 }
100 // Strip refs/heads/ prefix if present so we match git's branch
101 // resolution shape.
102 branch := strings.TrimPrefix(ref, "refs/heads/")
103
104 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
105 if err != nil {
106 h.d.Logger.ErrorContext(r.Context(), "actions dispatch: repo path", "error", err)
107 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
108 return
109 }
110 headSHA, err := repogit.ResolveRefOID(r.Context(), gitDir, branch)
111 if err != nil {
112 if errors.Is(err, repogit.ErrRefNotFound) {
113 h.d.Render.HTTPError(w, r, http.StatusNotFound, "ref "+branch+" not found")
114 return
115 }
116 h.d.Logger.WarnContext(r.Context(), "actions dispatch: resolve ref", "error", err)
117 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "could not resolve ref")
118 return
119 }
120
121 // Read + parse the specific workflow at this SHA. Saves a tree walk
122 // since the dispatch knows exactly which file to run.
123 bytes, err := repogit.ReadBlobBytes(r.Context(), gitDir, headSHA, file, int64(workflow.MaxWorkflowFileBytes))
124 if err != nil {
125 h.d.Render.HTTPError(w, r, http.StatusNotFound,
126 fmt.Sprintf("workflow file %q not found at ref %s", file, branch))
127 return
128 }
129 wf, diags, err := workflow.Parse(bytes)
130 if err != nil {
131 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "workflow parse: "+err.Error())
132 return
133 }
134 for _, d := range diags {
135 if d.Severity == workflow.Error {
136 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "workflow has Error diagnostics: "+d.String())
137 return
138 }
139 }
140 if wf.On.WorkflowDispatch == nil {
141 h.d.Render.HTTPError(w, r, http.StatusBadRequest,
142 "workflow does not declare on.workflow_dispatch")
143 return
144 }
145
146 // Each dispatch click produces a fresh trigger_event_id with a
147 // unique random suffix — the same workflow file at the same SHA
148 // can be dispatched multiple times by a human and each fires.
149 // (Compare the push trigger, which dedups on push_event_id.)
150 requestID, err := randHex(8)
151 if err != nil {
152 h.d.Logger.ErrorContext(r.Context(), "actions dispatch: rand", "error", err)
153 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
154 return
155 }
156 triggerID := fmt.Sprintf("dispatch:%s:%s:%s", file, headSHA, requestID)
157
158 viewer := middleware.CurrentUserFromContext(r.Context())
159 actorID := viewer.ID // 0 if anonymous, but RequireUser is in front of this route
160
161 payload := actionsevent.WorkflowDispatch(req.Inputs)
162 if _, err := trigger.Enqueue(r.Context(), trigger.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, trigger.EnqueueParams{
163 RepoID: row.ID,
164 WorkflowFile: file,
165 HeadSHA: headSHA,
166 HeadRef: "refs/heads/" + branch,
167 EventKind: trigger.EventWorkflowDispatch,
168 EventPayload: payload,
169 ActorUserID: actorID,
170 TriggerEventID: triggerID,
171 Workflow: wf,
172 }); err != nil {
173 h.d.Logger.ErrorContext(r.Context(), "actions dispatch: enqueue", "error", err)
174 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
175 return
176 }
177 w.WriteHeader(http.StatusNoContent)
178 }
179
180 // validWorkflowName guards against URL parameter shenanigans by
181 // requiring the resolved file path to look like
182 // `.shithub/workflows/<basename>.{yml,yaml}` with no path traversal.
183 func validWorkflowName(file string) bool {
184 if !strings.HasPrefix(file, ".shithub/workflows/") {
185 return false
186 }
187 rest := strings.TrimPrefix(file, ".shithub/workflows/")
188 if rest == "" || strings.ContainsAny(rest, "/\x00") {
189 return false
190 }
191 return strings.HasSuffix(rest, ".yml") || strings.HasSuffix(rest, ".yaml")
192 }
193
194 // randHex returns 2*n hex chars from crypto/rand. 8 bytes (16 hex
195 // chars) is plenty of entropy for a dispatch request id.
196 func randHex(n int) (string, error) {
197 b := make([]byte, n)
198 if _, err := rand.Read(b); err != nil {
199 return "", err
200 }
201 return hex.EncodeToString(b), nil
202 }
203