@@ -16,6 +16,7 @@ import ( |
| 16 | 16 | |
| 17 | 17 | "github.com/go-chi/chi/v5" |
| 18 | 18 | |
| 19 | + "github.com/tenseleyFlow/shithub/internal/actions/dispatch" |
| 19 | 20 | actionsevent "github.com/tenseleyFlow/shithub/internal/actions/event" |
| 20 | 21 | "github.com/tenseleyFlow/shithub/internal/actions/trigger" |
| 21 | 22 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
@@ -70,10 +71,8 @@ func (h *Handlers) repoActionsDispatch(w http.ResponseWriter, r *http.Request) { |
| 70 | 71 | // Workflows live under .shithub/workflows/. Authors can pass |
| 71 | 72 | // either "ci.yml" (basename) or ".shithub/workflows/ci.yml" (full |
| 72 | 73 | // 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) { |
| 74 | + file, err := dispatch.NormalizeFilePath(file) |
| 75 | + if err != nil { |
| 77 | 76 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid workflow file path") |
| 78 | 77 | return |
| 79 | 78 | } |
@@ -132,7 +131,7 @@ func (h *Handlers) repoActionsDispatch(w http.ResponseWriter, r *http.Request) { |
| 132 | 131 | "workflow does not declare on.workflow_dispatch") |
| 133 | 132 | return |
| 134 | 133 | } |
| 135 | | - inputs, err := normalizeDispatchInputs(req.Inputs, wf.On.WorkflowDispatch.Inputs) |
| 134 | + inputs, err := dispatch.NormalizeInputs(req.Inputs, wf.On.WorkflowDispatch.Inputs) |
| 136 | 135 | if err != nil { |
| 137 | 136 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, err.Error()) |
| 138 | 137 | return |
@@ -186,7 +185,7 @@ func (h *Handlers) parseDispatchRequest(w http.ResponseWriter, r *http.Request) |
| 186 | 185 | } |
| 187 | 186 | return dispatchRequest{ |
| 188 | 187 | Ref: strings.TrimSpace(r.PostFormValue("ref")), |
| 189 | | - Inputs: dispatchInputsFromForm(r.PostForm), |
| 188 | + Inputs: dispatch.InputsFromForm(r.PostForm), |
| 190 | 189 | }, true, true |
| 191 | 190 | } |
| 192 | 191 | |
@@ -222,90 +221,6 @@ func dispatchFormMediaType(r *http.Request) string { |
| 222 | 221 | } |
| 223 | 222 | } |
| 224 | 223 | |
| 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 | 224 | // randHex returns 2*n hex chars from crypto/rand. 8 bytes (16 hex |
| 310 | 225 | // chars) is plenty of entropy for a dispatch request id. |
| 311 | 226 | func randHex(n int) (string, error) { |