package triggers import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "log" "net/http" "time" "github.com/gin-gonic/gin" "go.starlark.net/starlark" "armature/models" "armature/sandbox" ) // HandleWebhook is the Gin handler for inbound webhook triggers. // Route: POST/GET /api/v1/hooks/:package_id/:slug func (e *Engine) HandleWebhook(c *gin.Context) { packageID := c.Param("package_id") slug := c.Param("slug") if e.stores.Triggers == nil { c.JSON(http.StatusNotFound, gin.H{"error": "triggers not available"}) return } // Resolve trigger trigger, err := e.stores.Triggers.GetWebhook(c.Request.Context(), packageID, slug) if err != nil { log.Printf(" ⚠️ webhook: lookup error %s/%s: %v", packageID, slug, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } if trigger == nil { c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"}) return } if !trigger.Enabled { c.JSON(http.StatusNotFound, gin.H{"error": "webhook disabled"}) return } // Read body body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) // 1MB limit if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"}) return } // Verify HMAC signature if secret is set if trigger.Secret != "" { sig := c.GetHeader("X-Armature-Signature") if !verifyHMAC(body, trigger.Secret, sig) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) return } } // Load package pkg, err := e.stores.Packages.Get(c.Request.Context(), packageID) if err != nil || pkg == nil || pkg.Status != models.PackageStatusActive { c.JSON(http.StatusNotFound, gin.H{"error": "package not available"}) return } // Check permission if !e.hasPermission(c.Request.Context(), packageID, models.ExtPermTriggersRegister) { c.JSON(http.StatusForbidden, gin.H{"error": "triggers.register permission not granted"}) return } // Build Starlark context dict ctxDict := starlark.NewDict(8) _ = ctxDict.SetKey(starlark.String("trigger_type"), starlark.String("webhook")) _ = ctxDict.SetKey(starlark.String("trigger_id"), starlark.String(trigger.ID)) _ = ctxDict.SetKey(starlark.String("method"), starlark.String(c.Request.Method)) _ = ctxDict.SetKey(starlark.String("path"), starlark.String(c.Request.URL.Path)) _ = ctxDict.SetKey(starlark.String("body"), starlark.String(string(body))) // Headers as dict headers := starlark.NewDict(len(c.Request.Header)) for k, v := range c.Request.Header { if len(v) > 0 { _ = headers.SetKey(starlark.String(k), starlark.String(v[0])) } } _ = ctxDict.SetKey(starlark.String("headers"), headers) // Query params as dict query := starlark.NewDict(len(c.Request.URL.Query())) for k, v := range c.Request.URL.Query() { if len(v) > 0 { _ = query.SetKey(starlark.String(k), starlark.String(v[0])) } } _ = ctxDict.SetKey(starlark.String("query"), query) // Fire synchronously — return result as HTTP response start := time.Now() val, output, callErr := e.runner.CallEntryPoint(c.Request.Context(), pkg, trigger.EntryPoint, starlark.Tuple{ctxDict}, nil, nil) duration := int(time.Since(start).Milliseconds()) errStr := "" if callErr != nil { errStr = callErr.Error() log.Printf(" ⚠️ trigger[webhook] %s/%s error: %v", packageID, slug, callErr) } // Update fire state _ = e.stores.Triggers.UpdateFireState(c.Request.Context(), trigger.ID, start, errStr) // Log execution e.logExecution(trigger.ID, "", start, duration, callErr == nil, errStr, output) e.publishEvent("trigger.fired", trigger.ID) if callErr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "handler error", "detail": errStr}) return } // Return Starlark result response := parseWebhookResponse(val) c.JSON(response.status, response.body) } // webhookResponse is the parsed response from a Starlark webhook handler. type webhookResponse struct { status int body any } // parseWebhookResponse extracts HTTP status and body from the Starlark return value. // Returns {status: X, body: Y} or default 200 OK. func parseWebhookResponse(val starlark.Value) webhookResponse { if val == nil || val == starlark.None { return webhookResponse{status: 200, body: gin.H{"ok": true}} } // If it's a dict with "status" and/or "body" keys, use them if d, ok := val.(*starlark.Dict); ok { resp := webhookResponse{status: 200} if statusVal, found, _ := d.Get(starlark.String("status")); found { if s, ok := statusVal.(starlark.Int); ok { if i, ok := s.Int64(); ok { resp.status = int(i) } } } if bodyVal, found, _ := d.Get(starlark.String("body")); found { resp.body = sandbox.StarlarkToGo(bodyVal) } else { resp.body = gin.H{"ok": true} } return resp } // String return → plain text body if s, ok := val.(starlark.String); ok { return webhookResponse{status: 200, body: gin.H{"result": string(s)}} } return webhookResponse{status: 200, body: gin.H{"ok": true}} } // verifyHMAC checks the HMAC-SHA256 signature of a webhook payload. func verifyHMAC(body []byte, secret, signature string) bool { if signature == "" { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) }