All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m17s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
182 lines
5.2 KiB
Go
182 lines
5.2 KiB
Go
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))
|
|
}
|