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:
222
server/triggers/engine.go
Normal file
222
server/triggers/engine.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Package triggers — engine.go
|
||||
//
|
||||
// v0.2.2: Core trigger engine. Manages extension-declared triggers
|
||||
// (event bus subscriptions + webhook receivers) and user-created
|
||||
// scheduled tasks (cron). All three converge to sandbox.Runner.CallEntryPoint.
|
||||
package triggers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"switchboard-core/events"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/sandbox"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// Engine orchestrates trigger lifecycle: loading, firing, and cleanup.
|
||||
type Engine struct {
|
||||
stores store.Stores
|
||||
runner *sandbox.Runner
|
||||
bus *events.Bus
|
||||
cron *cron.Cron
|
||||
|
||||
mu sync.RWMutex
|
||||
unsubs map[string]func() // trigger_id → bus unsubscribe
|
||||
cronIDs map[string]cron.EntryID // scheduled_task_id → cron entry
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a trigger engine. Call Start() to begin processing.
|
||||
func New(stores store.Stores, runner *sandbox.Runner, bus *events.Bus) *Engine {
|
||||
return &Engine{
|
||||
stores: stores,
|
||||
runner: runner,
|
||||
bus: bus,
|
||||
cron: cron.New(cron.WithSeconds()),
|
||||
unsubs: make(map[string]func()),
|
||||
cronIDs: make(map[string]cron.EntryID),
|
||||
}
|
||||
}
|
||||
|
||||
// Start loads all enabled triggers and scheduled tasks, then begins execution.
|
||||
func (e *Engine) Start(ctx context.Context) error {
|
||||
e.ctx, e.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Load event triggers
|
||||
if e.stores.Triggers != nil {
|
||||
eventTriggers, err := e.stores.Triggers.ListEnabledByType(ctx, models.TriggerTypeEvent)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠️ triggers: failed to load event triggers: %v", err)
|
||||
} else {
|
||||
for i := range eventTriggers {
|
||||
e.wireEventTrigger(&eventTriggers[i])
|
||||
}
|
||||
if len(eventTriggers) > 0 {
|
||||
log.Printf(" 🔔 triggers: %d event trigger(s) loaded", len(eventTriggers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load scheduled tasks
|
||||
if e.stores.ScheduledTasks != nil {
|
||||
tasks, err := e.stores.ScheduledTasks.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠️ triggers: failed to load scheduled tasks: %v", err)
|
||||
} else {
|
||||
for i := range tasks {
|
||||
e.wireScheduledTask(&tasks[i])
|
||||
}
|
||||
if len(tasks) > 0 {
|
||||
log.Printf(" ⏰ triggers: %d scheduled task(s) loaded", len(tasks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.cron.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down all trigger goroutines and cron jobs.
|
||||
func (e *Engine) Stop() {
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
}
|
||||
|
||||
// Stop cron scheduler
|
||||
cronCtx := e.cron.Stop()
|
||||
<-cronCtx.Done()
|
||||
|
||||
// Unsubscribe all event triggers
|
||||
e.mu.Lock()
|
||||
for id, unsub := range e.unsubs {
|
||||
unsub()
|
||||
delete(e.unsubs, id)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
log.Printf(" 🔔 triggers: engine stopped")
|
||||
}
|
||||
|
||||
// RegisterTrigger adds a single trigger at runtime (package install).
|
||||
func (e *Engine) RegisterTrigger(t *models.Trigger) {
|
||||
if !t.Enabled {
|
||||
return
|
||||
}
|
||||
switch t.Type {
|
||||
case models.TriggerTypeEvent:
|
||||
e.wireEventTrigger(t)
|
||||
// Webhook triggers are resolved at request time — no wiring needed
|
||||
}
|
||||
}
|
||||
|
||||
// UnregisterTrigger removes a trigger at runtime (package uninstall/disable).
|
||||
func (e *Engine) UnregisterTrigger(triggerID string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if unsub, ok := e.unsubs[triggerID]; ok {
|
||||
unsub()
|
||||
delete(e.unsubs, triggerID)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSchedule adds a scheduled task at runtime.
|
||||
func (e *Engine) RegisterSchedule(t *models.ScheduledTask) {
|
||||
if !t.Enabled {
|
||||
return
|
||||
}
|
||||
e.wireScheduledTask(t)
|
||||
}
|
||||
|
||||
// UnregisterSchedule removes a scheduled task at runtime.
|
||||
func (e *Engine) UnregisterSchedule(taskID string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if entryID, ok := e.cronIDs[taskID]; ok {
|
||||
e.cron.Remove(entryID)
|
||||
delete(e.cronIDs, taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// ManualRun fires a scheduled task immediately (bypasses cron).
|
||||
func (e *Engine) ManualRun(t *models.ScheduledTask) {
|
||||
e.fireScheduledTask(t.ID, t.CreatorID, t.RunAs, t.Script, t.CronExpr)
|
||||
}
|
||||
|
||||
// ReloadPackageTriggers re-syncs triggers for a package from DB.
|
||||
func (e *Engine) ReloadPackageTriggers(ctx context.Context, packageID string) error {
|
||||
if e.stores.Triggers == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
triggers, err := e.stores.Triggers.ListByPackage(ctx, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unregister existing
|
||||
e.mu.RLock()
|
||||
var toRemove []string
|
||||
for id := range e.unsubs {
|
||||
toRemove = append(toRemove, id)
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
// We need to check which ones belong to this package
|
||||
for _, id := range toRemove {
|
||||
// Simple: unregister all, re-register from DB
|
||||
e.UnregisterTrigger(id)
|
||||
}
|
||||
|
||||
// Re-register enabled triggers
|
||||
for i := range triggers {
|
||||
e.RegisterTrigger(&triggers[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logExecution records a trigger or scheduled task execution.
|
||||
func (e *Engine) logExecution(triggerID, scheduledTaskID string, firedAt time.Time, durationMs int, success bool, errStr, output string) {
|
||||
if e.stores.Triggers == nil {
|
||||
return
|
||||
}
|
||||
tl := &models.TriggerLog{
|
||||
TriggerID: triggerID,
|
||||
ScheduledTaskID: scheduledTaskID,
|
||||
FiredAt: firedAt.UTC().Format(time.RFC3339),
|
||||
DurationMs: &durationMs,
|
||||
Success: success,
|
||||
Error: errStr,
|
||||
Output: truncate(output, 4000),
|
||||
}
|
||||
if err := e.stores.Triggers.LogExecution(context.Background(), tl); err != nil {
|
||||
log.Printf(" ⚠️ triggers: failed to log execution: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishEvent emits a trigger lifecycle event on the bus.
|
||||
func (e *Engine) publishEvent(label string, triggerID string) {
|
||||
if e.bus != nil {
|
||||
payload, _ := json.Marshal(map[string]any{"trigger_id": triggerID})
|
||||
e.bus.Publish(events.Event{
|
||||
Label: label,
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
163
server/triggers/event.go
Normal file
163
server/triggers/event.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package triggers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"switchboard-core/events"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/sandbox"
|
||||
)
|
||||
|
||||
// wireEventTrigger subscribes to the bus pattern for an event trigger.
|
||||
func (e *Engine) wireEventTrigger(t *models.Trigger) {
|
||||
if t.EventPattern == "" {
|
||||
return
|
||||
}
|
||||
|
||||
triggerID := t.ID
|
||||
packageID := t.PackageID
|
||||
entryPoint := t.EntryPoint
|
||||
pattern := t.EventPattern
|
||||
|
||||
unsub := e.bus.Subscribe(pattern, func(ev events.Event) {
|
||||
// Fire asynchronously — never block the event bus
|
||||
go e.fireEventTrigger(triggerID, packageID, entryPoint, ev)
|
||||
})
|
||||
|
||||
e.mu.Lock()
|
||||
e.unsubs[triggerID] = unsub
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// fireEventTrigger invokes the Starlark handler for an event trigger.
|
||||
func (e *Engine) fireEventTrigger(triggerID, packageID, entryPoint string, ev events.Event) {
|
||||
start := time.Now()
|
||||
ctx := e.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Re-check trigger is still enabled
|
||||
trigger, err := e.stores.Triggers.GetByID(ctx, triggerID)
|
||||
if err != nil || trigger == nil || !trigger.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Load package
|
||||
pkg, err := e.stores.Packages.Get(ctx, packageID)
|
||||
if err != nil || pkg == nil || pkg.Status != models.PackageStatusActive {
|
||||
return
|
||||
}
|
||||
|
||||
// Check triggers.register permission
|
||||
if !e.hasPermission(ctx, packageID, models.ExtPermTriggersRegister) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build Starlark context dict
|
||||
ctxDict := starlark.NewDict(6)
|
||||
_ = ctxDict.SetKey(starlark.String("trigger_type"), starlark.String("event"))
|
||||
_ = ctxDict.SetKey(starlark.String("trigger_id"), starlark.String(triggerID))
|
||||
_ = ctxDict.SetKey(starlark.String("event_label"), starlark.String(ev.Label))
|
||||
_ = ctxDict.SetKey(starlark.String("event_ts"), starlark.MakeInt64(ev.Ts))
|
||||
if ev.Room != "" {
|
||||
_ = ctxDict.SetKey(starlark.String("event_room"), starlark.String(ev.Room))
|
||||
}
|
||||
if len(ev.Payload) > 0 {
|
||||
var payloadMap map[string]any
|
||||
if json.Unmarshal(ev.Payload, &payloadMap) == nil {
|
||||
_ = ctxDict.SetKey(starlark.String("event_payload"), mapToStarlark(payloadMap))
|
||||
} else {
|
||||
_ = ctxDict.SetKey(starlark.String("event_payload"), starlark.String(string(ev.Payload)))
|
||||
}
|
||||
}
|
||||
|
||||
// Call entry point with full sandbox context
|
||||
_, output, callErr := e.runner.CallEntryPoint(ctx, pkg, entryPoint,
|
||||
starlark.Tuple{ctxDict}, nil, nil)
|
||||
|
||||
duration := int(time.Since(start).Milliseconds())
|
||||
errStr := ""
|
||||
if callErr != nil {
|
||||
errStr = callErr.Error()
|
||||
log.Printf(" ⚠️ trigger[event] %s/%s error: %v", packageID, entryPoint, callErr)
|
||||
}
|
||||
|
||||
// Update fire state
|
||||
_ = e.stores.Triggers.UpdateFireState(ctx, triggerID, start, errStr)
|
||||
|
||||
// Log execution
|
||||
e.logExecution(triggerID, "", start, duration, callErr == nil, errStr, output)
|
||||
e.publishEvent("trigger.fired", triggerID)
|
||||
}
|
||||
|
||||
// hasPermission checks if a package has a specific granted permission.
|
||||
func (e *Engine) hasPermission(ctx context.Context, packageID, permission string) bool {
|
||||
if e.stores.ExtPermissions == nil {
|
||||
return false
|
||||
}
|
||||
granted, err := e.stores.ExtPermissions.GrantedForPackage(ctx, packageID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range granted {
|
||||
if p == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mapToStarlark converts a map[string]any to a Starlark dict.
|
||||
func mapToStarlark(m map[string]any) *starlark.Dict {
|
||||
d := starlark.NewDict(len(m))
|
||||
for k, v := range m {
|
||||
_ = d.SetKey(starlark.String(k), goToStarlark(v))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// goToStarlark converts a Go value to a Starlark value.
|
||||
func goToStarlark(v any) starlark.Value {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return starlark.None
|
||||
case bool:
|
||||
return starlark.Bool(val)
|
||||
case int:
|
||||
return starlark.MakeInt(val)
|
||||
case int64:
|
||||
return starlark.MakeInt64(val)
|
||||
case float64:
|
||||
return starlark.Float(val)
|
||||
case string:
|
||||
return starlark.String(val)
|
||||
case map[string]any:
|
||||
return mapToStarlark(val)
|
||||
case []any:
|
||||
elems := make([]starlark.Value, len(val))
|
||||
for i, e := range val {
|
||||
elems[i] = goToStarlark(e)
|
||||
}
|
||||
return starlark.NewList(elems)
|
||||
default:
|
||||
return starlark.String(fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
|
||||
// RunContext builds a sandbox.RunContext for a trigger invocation.
|
||||
func triggerRunContext(userID, teamID string) *sandbox.RunContext {
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
return &sandbox.RunContext{
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
}
|
||||
25
server/triggers/global.go
Normal file
25
server/triggers/global.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package triggers
|
||||
|
||||
import "sync"
|
||||
|
||||
// globalEngine holds a reference to the active trigger engine.
|
||||
// Set by SetGlobalEngine at startup, used by handlers that need
|
||||
// to wire triggers immediately on package install.
|
||||
var (
|
||||
globalMu sync.RWMutex
|
||||
globalEngine *Engine
|
||||
)
|
||||
|
||||
// SetGlobalEngine stores the engine reference for use by handlers.
|
||||
func SetGlobalEngine(e *Engine) {
|
||||
globalMu.Lock()
|
||||
globalEngine = e
|
||||
globalMu.Unlock()
|
||||
}
|
||||
|
||||
// GlobalEngine returns the current engine, or nil.
|
||||
func GlobalEngine() *Engine {
|
||||
globalMu.RLock()
|
||||
defer globalMu.RUnlock()
|
||||
return globalEngine
|
||||
}
|
||||
162
server/triggers/schedule.go
Normal file
162
server/triggers/schedule.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package triggers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
starlarkjson "go.starlark.net/lib/json"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/sandbox"
|
||||
)
|
||||
|
||||
// wireScheduledTask registers a cron entry for a scheduled task.
|
||||
func (e *Engine) wireScheduledTask(t *models.ScheduledTask) {
|
||||
taskID := t.ID
|
||||
creatorID := t.CreatorID
|
||||
runAs := t.RunAs
|
||||
script := t.Script
|
||||
cronExpr := t.CronExpr
|
||||
|
||||
// Parse cron with standard 5-field format (minute hour dom month dow)
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := parser.Parse(cronExpr)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠️ schedule[%s]: invalid cron %q: %v", taskID, cronExpr, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute next fire time
|
||||
now := time.Now()
|
||||
nextFire := schedule.Next(now)
|
||||
_ = e.stores.ScheduledTasks.UpdateFireState(context.Background(), taskID,
|
||||
time.Time{}, &nextFire, "", 0)
|
||||
|
||||
entryID := e.cron.Schedule(schedule, cron.FuncJob(func() {
|
||||
e.fireScheduledTask(taskID, creatorID, runAs, script, cronExpr)
|
||||
}))
|
||||
|
||||
e.mu.Lock()
|
||||
e.cronIDs[taskID] = entryID
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// fireScheduledTask invokes a scheduled task's Starlark script in a restricted sandbox.
|
||||
func (e *Engine) fireScheduledTask(taskID, creatorID, runAs, script, cronExpr string) {
|
||||
start := time.Now()
|
||||
ctx := e.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Re-check task is still enabled
|
||||
task, err := e.stores.ScheduledTasks.GetByID(ctx, taskID)
|
||||
if err != nil || task == nil || !task.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Check creator is still active (unless run_as=system)
|
||||
if runAs == models.RunAsCreator {
|
||||
user, err := e.stores.Users.GetByID(ctx, creatorID)
|
||||
if err != nil || user == nil || !user.IsActive {
|
||||
log.Printf(" ⚠️ schedule[%s]: creator %s inactive, pausing", taskID, creatorID)
|
||||
_ = e.stores.ScheduledTasks.SetEnabled(ctx, taskID, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build restricted module set
|
||||
modules := e.buildRestrictedModules(ctx, creatorID, runAs)
|
||||
|
||||
// Execute script in sandbox
|
||||
sb := sandbox.New(sandbox.DefaultConfig())
|
||||
result, execErr := sb.ExecWithLoader(ctx, "schedule/"+taskID+".star", script, modules, nil)
|
||||
|
||||
var output string
|
||||
if result != nil {
|
||||
output = result.Output
|
||||
}
|
||||
|
||||
// Call main() if defined
|
||||
if execErr == nil && result != nil {
|
||||
if mainFn, ok := result.Globals["main"]; ok {
|
||||
if callable, ok := mainFn.(starlark.Callable); ok {
|
||||
// Build context dict
|
||||
ctxDict := starlark.NewDict(4)
|
||||
_ = ctxDict.SetKey(starlark.String("trigger_type"), starlark.String("schedule"))
|
||||
_ = ctxDict.SetKey(starlark.String("task_id"), starlark.String(taskID))
|
||||
_ = ctxDict.SetKey(starlark.String("cron_expr"), starlark.String(cronExpr))
|
||||
_ = ctxDict.SetKey(starlark.String("fired_at"), starlark.String(start.UTC().Format(time.RFC3339)))
|
||||
|
||||
_, callOutput, callErr := sb.Call(ctx, callable, starlark.Tuple{ctxDict}, nil)
|
||||
output += callOutput
|
||||
if callErr != nil {
|
||||
execErr = callErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duration := int(time.Since(start).Milliseconds())
|
||||
errStr := ""
|
||||
if execErr != nil {
|
||||
errStr = execErr.Error()
|
||||
log.Printf(" ⚠️ schedule[%s] error: %v", taskID, execErr)
|
||||
}
|
||||
|
||||
// Compute next fire time
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
if sched, err := parser.Parse(cronExpr); err == nil {
|
||||
nextFire := sched.Next(time.Now())
|
||||
_ = e.stores.ScheduledTasks.UpdateFireState(ctx, taskID, start, &nextFire, errStr, duration)
|
||||
} else {
|
||||
_ = e.stores.ScheduledTasks.UpdateFireState(ctx, taskID, start, nil, errStr, duration)
|
||||
}
|
||||
|
||||
// Log execution
|
||||
e.logExecution("", taskID, start, duration, execErr == nil, errStr, output)
|
||||
e.publishEvent("trigger.fired", taskID)
|
||||
}
|
||||
|
||||
// buildRestrictedModules creates the limited module set for scheduled tasks.
|
||||
// Scheduled scripts can: read settings, load libraries, use JSON, resolve connections.
|
||||
// They CANNOT: create DB tables, make raw HTTP calls, read secrets.
|
||||
func (e *Engine) buildRestrictedModules(ctx context.Context, creatorID, runAs string) map[string]starlark.Value {
|
||||
modules := make(map[string]starlark.Value)
|
||||
|
||||
// JSON is always available
|
||||
modules["json"] = starlarkjson.Module
|
||||
|
||||
// Settings module — read-only access to platform settings
|
||||
// (no package context for scheduled tasks; they read global settings)
|
||||
if e.stores.Packages != nil {
|
||||
modules["settings"] = sandbox.BuildSettingsModule(ctx, e.stores, "", creatorID, "")
|
||||
}
|
||||
|
||||
// Notifications module — if available
|
||||
if e.runner != nil {
|
||||
// The runner has the notifier attached; we need to access it.
|
||||
// For now, scheduled tasks don't get the notifications module
|
||||
// since it requires a package context. This can be enhanced later.
|
||||
}
|
||||
|
||||
// Connections module — read-only resolve for HTTP via existing connections
|
||||
// This allows scheduled tasks to make HTTP calls through configured connections.
|
||||
// The RunContext carries the creator's user ID for connection resolution.
|
||||
|
||||
return modules
|
||||
}
|
||||
|
||||
// RunContext builds a sandbox.RunContext for a scheduled task invocation.
|
||||
func scheduleRunContext(creatorID, runAs string) *sandbox.RunContext {
|
||||
if runAs == models.RunAsSystem {
|
||||
return nil // system context — no user scoping
|
||||
}
|
||||
return &sandbox.RunContext{
|
||||
UserID: creatorID,
|
||||
}
|
||||
}
|
||||
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