Feat triggers v0.2.2 (#6)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
215
server/triggers/webhook.go
Normal file
215
server/triggers/webhook.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package triggers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"switchboard-core/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-Switchboard-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))
|
||||
}
|
||||
Reference in New Issue
Block a user