This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/triggers/webhook.go
Jeffrey Smith 680ec3b897
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Feat rebrand armature (#43)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00

216 lines
6.0 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"
)
// 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 = 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}}
}
// starlarkToGo converts a Starlark value to a Go value (for JSON serialization).
func starlarkToGo(v starlark.Value) any {
switch val := v.(type) {
case starlark.NoneType:
return nil
case starlark.Bool:
return bool(val)
case starlark.Int:
if i, ok := val.Int64(); ok {
return i
}
return val.String()
case starlark.Float:
return float64(val)
case starlark.String:
return string(val)
case *starlark.List:
result := make([]any, val.Len())
for i := 0; i < val.Len(); i++ {
result[i] = starlarkToGo(val.Index(i))
}
return result
case *starlark.Dict:
result := make(map[string]any, val.Len())
for _, item := range val.Items() {
if k, ok := item[0].(starlark.String); ok {
result[string(k)] = starlarkToGo(item[1])
}
}
return result
default:
return v.String()
}
}
// 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))
}