diff --git a/service/go.mod b/service/go.mod index 132a6ec..3d53c97 100644 --- a/service/go.mod +++ b/service/go.mod @@ -10,6 +10,7 @@ require ( connectrpc.com/connect v1.19.1 github.com/Masterminds/semver v1.5.0 github.com/MicahParks/keyfunc/v3 v3.7.0 + github.com/PaesslerAG/jsonpath v0.1.1 github.com/alexedwards/argon2id v1.0.0 github.com/bufbuild/buf v1.61.0 github.com/fsnotify/fsnotify v1.9.0 @@ -55,6 +56,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MicahParks/jwkset v0.11.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PaesslerAG/gval v1.0.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect diff --git a/service/go.sum b/service/go.sum index 8af88a2..9d82fe9 100644 --- a/service/go.sum +++ b/service/go.sum @@ -42,6 +42,11 @@ github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3 github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= +github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 722e492..55b5b29 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -22,12 +22,14 @@ type Action struct { ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"` ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"` ExecOnCalendarFile string `koanf:"execOnCalendarFile"` + ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"` Triggers []string `koanf:"triggers"` MaxConcurrent int `koanf:"maxConcurrent"` MaxRate []RateSpec `koanf:"maxRate"` Arguments []ActionArgument `koanf:"arguments"` PopupOnStart string `koanf:"popupOnStart"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` + EnabledExpression string `koanf:"enabledExpression"` } // ActionArgument objects appear on Actions. @@ -55,6 +57,18 @@ type RateSpec struct { Duration string `koanf:"duration"` } +// WebhookConfig defines configuration for generic webhook triggers. +type WebhookConfig struct { + Secret string `koanf:"secret"` // Optional: secret for signature verification + AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none" + AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature") + MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers + MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath") + MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters + Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions + Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr") +} + // Entity represents a "thing" that can have multiple actions associated with it. // for example, a media player with a start and stop action. type EntityFile struct { diff --git a/service/internal/httpservers/frontend.go b/service/internal/httpservers/frontend.go index cda9cb5..9860650 100644 --- a/service/internal/httpservers/frontend.go +++ b/service/internal/httpservers/frontend.go @@ -19,6 +19,7 @@ import ( "github.com/OliveTin/OliveTin/internal/auth/otoauth2" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" + "github.com/OliveTin/OliveTin/internal/webhooks" log "github.com/sirupsen/logrus" ) @@ -72,6 +73,10 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) { mux.HandleFunc("/readyz", handleReadyz) + webhookHandler := webhooks.NewWebhookHandler(cfg, ex) + mux.HandleFunc("/webhooks", webhookHandler.HandleWebhook) + mux.HandleFunc("/webhooks/", webhookHandler.HandleWebhook) + webuiServer := NewWebUIServer(cfg) mux.HandleFunc("/theme.css", webuiServer.generateThemeCss) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go new file mode 100644 index 0000000..b03ce99 --- /dev/null +++ b/service/internal/webhooks/auth.go @@ -0,0 +1,169 @@ +package webhooks + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" + "strings" + + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type AuthVerifier struct { + config config.WebhookConfig +} + +func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier { + return &AuthVerifier{config: cfg} +} + +func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool { + verifier := v.getVerifier() + if verifier == nil { + return v.handleUnknownAuthType() + } + return verifier(r, payload) +} + +type authVerifierFunc func(*http.Request, []byte) bool + +func (v *AuthVerifier) getVerifier() authVerifierFunc { + if v.config.AuthType == "" || v.config.AuthType == "none" { + return func(_ *http.Request, _ []byte) bool { + return true + } + } + + verifierMap := v.buildVerifierMap() + if verifier, ok := verifierMap[v.config.AuthType]; ok { + return verifier + } + return nil +} + +func (v *AuthVerifier) buildVerifierMap() map[string]authVerifierFunc { + return map[string]authVerifierFunc{ + "hmac-sha256": v.verifyHMAC256, + "hmac-sha1": v.verifyHMAC1, + "bearer": func(r *http.Request, _ []byte) bool { + return v.verifyBearer(r) + }, + "basic": func(r *http.Request, _ []byte) bool { + return v.verifyBasic(r) + }, + } +} + +func (v *AuthVerifier) handleUnknownAuthType() bool { + log.WithFields(log.Fields{ + "authType": v.config.AuthType, + }).Warnf("Unknown auth type, rejecting") + return false +} + +func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool { + if v.config.Secret == "" { + log.Warnf("HMAC-SHA256 auth requires secret") + return false + } + + headerName := v.config.AuthHeader + if headerName == "" { + headerName = "X-Webhook-Signature" + } + + signature := r.Header.Get(headerName) + if signature == "" { + log.Debugf("Missing signature header: %s", headerName) + return false + } + + expectedSig := strings.TrimPrefix(signature, "sha256=") + + mac := hmac.New(sha256.New, []byte(v.config.Secret)) + mac.Write(payload) + computedSig := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expectedSig), []byte(computedSig)) +} + +func (v *AuthVerifier) verifyHMAC1(r *http.Request, payload []byte) bool { + if v.config.Secret == "" { + log.Warnf("HMAC-SHA1 auth requires secret") + return false + } + + headerName := v.config.AuthHeader + if headerName == "" { + headerName = "X-Webhook-Signature" + } + + signature := r.Header.Get(headerName) + if signature == "" { + log.Debugf("Missing signature header: %s", headerName) + return false + } + + expectedSig := strings.TrimPrefix(signature, "sha1=") + + mac := hmac.New(sha1.New, []byte(v.config.Secret)) + mac.Write(payload) + computedSig := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expectedSig), []byte(computedSig)) +} + +func (v *AuthVerifier) verifyBearer(r *http.Request) bool { + if v.config.Secret == "" { + log.Warnf("Bearer auth requires secret") + return false + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + log.Debugf("Missing or invalid Bearer token") + return false + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + tokenBytes := []byte(token) + secretBytes := []byte(v.config.Secret) + return len(tokenBytes) == len(secretBytes) && subtle.ConstantTimeCompare(tokenBytes, secretBytes) == 1 +} + +func (v *AuthVerifier) verifyBasic(r *http.Request) bool { + if v.config.Secret == "" { + log.Warnf("Basic auth requires secret") + return false + } + + username, password, ok := r.BasicAuth() + if !ok { + log.Debugf("Missing Basic auth header") + return false + } + + return v.verifyBasicCredentials(username, password) +} + +func (v *AuthVerifier) verifyBasicCredentials(username, password string) bool { + parts := strings.SplitN(v.config.Secret, ":", 2) + if len(parts) == 2 { + return v.verifyBasicWithUsername(username, password, parts[0], parts[1]) + } + return v.verifyBasicPasswordOnly(password) +} + +func (v *AuthVerifier) verifyBasicWithUsername(username, password, expectedUsername, expectedPassword string) bool { + usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) + passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) + return usernameMatch == 1 && passwordMatch == 1 +} + +func (v *AuthVerifier) verifyBasicPasswordOnly(password string) bool { + return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1 +} diff --git a/service/internal/webhooks/github.go b/service/internal/webhooks/github.go new file mode 100644 index 0000000..431e6ba --- /dev/null +++ b/service/internal/webhooks/github.go @@ -0,0 +1,112 @@ +package webhooks + +import ( + "github.com/OliveTin/OliveTin/internal/config" +) + +// ApplyGitHubTemplate applies GitHub-specific template configurations +// This allows users to use simple template names instead of configuring everything manually +func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) { + applier := getTemplateApplier(template) + if applier != nil { + applier(cfg) + } +} + +type templateApplier func(*config.WebhookConfig) + +func getTemplateApplier(template string) templateApplier { + templateMap := map[string]templateApplier{ + "github-push": applyGitHubPushTemplate, + "github-pr": applyGitHubPRTemplate, + "github-pull-request": applyGitHubPRTemplate, + "github-release": applyGitHubReleaseTemplate, + "github-workflow": applyGitHubWorkflowTemplate, + } + return templateMap[template] +} + +func setDefaultAuth(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } +} + +func ensureMatchHeaders(cfg *config.WebhookConfig) { + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } +} + +func ensureExtract(cfg *config.WebhookConfig) { + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } +} + +func setExtractIfMissing(cfg *config.WebhookConfig, key, value string) { + if _, exists := cfg.Extract[key]; !exists { + cfg.Extract[key] = value + } +} + +func setMatchHeaderIfMissing(cfg *config.WebhookConfig, key, value string) { + if _, exists := cfg.MatchHeaders[key]; !exists { + cfg.MatchHeaders[key] = value + } +} + +func applyGitHubPushTemplate(cfg *config.WebhookConfig) { + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "push") + ensureExtract(cfg) + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "git_ref", "$.ref") + setExtractIfMissing(cfg, "git_commit", "$.head_commit.id") + setExtractIfMissing(cfg, "git_branch", "$.ref") + setExtractIfMissing(cfg, "git_message", "$.head_commit.message") + setExtractIfMissing(cfg, "git_author", "$.head_commit.author.name") +} + +func applyGitHubPRTemplate(cfg *config.WebhookConfig) { + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "pull_request") + ensureExtract(cfg) + setExtractIfMissing(cfg, "pr_number", "$.number") + setExtractIfMissing(cfg, "pr_title", "$.pull_request.title") + setExtractIfMissing(cfg, "pr_author", "$.pull_request.user.login") + setExtractIfMissing(cfg, "pr_action", "$.action") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "pr_state", "$.pull_request.state") + setExtractIfMissing(cfg, "pr_head_sha", "$.pull_request.head.sha") +} + +func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) { + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "release") + ensureExtract(cfg) + setExtractIfMissing(cfg, "release_action", "$.action") + setExtractIfMissing(cfg, "release_tag", "$.release.tag_name") + setExtractIfMissing(cfg, "release_name", "$.release.name") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "release_author", "$.release.author.login") +} + +func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) { + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "workflow_run") + ensureExtract(cfg) + setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name") + setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status") + setExtractIfMissing(cfg, "workflow_conclusion", "$.workflow_run.conclusion") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "git_commit", "$.workflow_run.head_sha") + setExtractIfMissing(cfg, "git_branch", "$.workflow_run.head_branch") +} diff --git a/service/internal/webhooks/handler.go b/service/internal/webhooks/handler.go new file mode 100644 index 0000000..a530690 --- /dev/null +++ b/service/internal/webhooks/handler.go @@ -0,0 +1,162 @@ +package webhooks + +import ( + "io" + "net/http" + + "github.com/OliveTin/OliveTin/internal/auth" + "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" + log "github.com/sirupsen/logrus" +) + +type ActionWebhookConfig struct { + Action *config.Action + Config config.WebhookConfig +} + +type WebhookHandler struct { + cfg *config.Config + executor *executor.Executor +} + +func NewWebhookHandler(cfg *config.Config, ex *executor.Executor) *WebhookHandler { + return &WebhookHandler{ + cfg: cfg, + executor: ex, + } +} + +func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + payload, err := h.readPayload(r) + if err != nil { + http.Error(w, "Failed to read payload", http.StatusBadRequest) + return + } + + matchingActions := h.findMatchingActions(r, payload) + if len(matchingActions) == 0 { + h.writeOKResponse(w, "no matching webhook actions") + return + } + + processed := h.processMatchingActions(matchingActions, r, payload) + log.WithFields(log.Fields{ + "matched": len(matchingActions), + "processed": processed, + }).Infof("Webhook processed") + + h.writeOKResponse(w, "webhook actions") +} + +func (h *WebhookHandler) readPayload(r *http.Request) ([]byte, error) { + maxSize := int64(1024 * 1024) + payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize)) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Warnf("Failed to read webhook payload") + return nil, err + } + + return payload, nil +} + +func (h *WebhookHandler) writeOKResponse(w http.ResponseWriter, context string) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("OK")); err != nil { + log.WithError(err).Warnf("Failed to write response for %s", context) + } +} + +func (h *WebhookHandler) processMatchingActions(matchingActions []ActionWebhookConfig, r *http.Request, payload []byte) int { + processed := 0 + for _, actionConfig := range matchingActions { + if h.processWebhook(actionConfig, r, payload) { + processed++ + } + } + return processed +} + +func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte) []ActionWebhookConfig { + var matches []ActionWebhookConfig + + for _, action := range h.cfg.Actions { + matches = append(matches, h.findMatchingWebhooksForAction(action, r, payload)...) + } + + return matches +} + +func (h *WebhookHandler) findMatchingWebhooksForAction(action *config.Action, r *http.Request, payload []byte) []ActionWebhookConfig { + var matches []ActionWebhookConfig + + for _, webhookConfig := range action.ExecOnWebhook { + webhookConfigCopy := webhookConfig + + if webhookConfigCopy.Template != "" { + ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template) + } + + matcher := NewWebhookMatcher(webhookConfigCopy, r, payload) + if matcher.Matches() { + matches = append(matches, ActionWebhookConfig{ + Action: action, + Config: webhookConfigCopy, + }) + } + } + + return matches +} + +func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *http.Request, payload []byte) bool { + verifier := NewAuthVerifier(actionConfig.Config) + if !verifier.Verify(r, payload) { + log.WithFields(log.Fields{ + "actionTitle": actionConfig.Action.Title, + "authType": actionConfig.Config.AuthType, + }).Warnf("Webhook authentication failed") + return false + } + + matcher := NewWebhookMatcher(actionConfig.Config, r, payload) + + args, err := matcher.ExtractArguments() + if err != nil { + log.WithFields(log.Fields{ + "actionTitle": actionConfig.Action.Title, + "error": err, + }).Warnf("Failed to extract webhook arguments") + return false + } + + h.executeAction(actionConfig.Action, args) + return true +} + +func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) { + binding := h.executor.FindBindingWithNoEntity(action) + if binding == nil { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + }).Warnf("Action binding not found, skipping execution") + return + } + + req := &executor.ExecutionRequest{ + Binding: binding, + Cfg: h.cfg, + Tags: []string{"webhook"}, + Arguments: args, + AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"), + } + + h.executor.ExecRequest(req) +} diff --git a/service/internal/webhooks/jsonpath.go b/service/internal/webhooks/jsonpath.go new file mode 100644 index 0000000..7fc5309 --- /dev/null +++ b/service/internal/webhooks/jsonpath.go @@ -0,0 +1,55 @@ +package webhooks + +import ( + "encoding/json" + "fmt" + + "github.com/PaesslerAG/jsonpath" +) + +type JSONMatcher struct { + payload interface{} +} + +func NewJSONMatcher(payload []byte) (*JSONMatcher, error) { + var data interface{} + if err := json.Unmarshal(payload, &data); err != nil { + return nil, err + } + return &JSONMatcher{payload: data}, nil +} + +func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, error) { + value, err := jsonpath.Get(pathExpr, m.payload) + if err != nil { + return false, err + } + + // Marshal to JSON for consistent string representation + jsonBytes, err := json.Marshal(value) + if err != nil { + return false, fmt.Errorf("failed to marshal extracted value: %w", err) + } + + valueStr := string(jsonBytes) + return valueStr == expectedValue, nil +} + +func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) { + value, err := jsonpath.Get(pathExpr, m.payload) + if err != nil { + return "", err + } + + // Marshal to JSON for consistent string representation + jsonBytes, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal extracted value: %w", err) + } + + return string(jsonBytes), nil +} + +func (m *JSONMatcher) GetPayload() interface{} { + return m.payload +} diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go new file mode 100644 index 0000000..3d30d5e --- /dev/null +++ b/service/internal/webhooks/matcher.go @@ -0,0 +1,185 @@ +package webhooks + +import ( + "net/http" + "regexp" + "strings" + + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type WebhookMatcher struct { + config config.WebhookConfig + req *http.Request + bodyBytes []byte +} + +func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte) *WebhookMatcher { + return &WebhookMatcher{ + config: cfg, + req: r, + bodyBytes: bodyBytes, + } +} + +func (m *WebhookMatcher) Matches() bool { + if !m.matchHeaders() { + return false + } + + if !m.matchQuery() { + return false + } + + if !m.matchPath() { + return false + } + + return true +} + +func (m *WebhookMatcher) matchHeaders() bool { + if len(m.config.MatchHeaders) == 0 { + return true + } + + for key, expectedValue := range m.config.MatchHeaders { + actualValue := m.req.Header.Get(key) + if !m.compareValues(actualValue, expectedValue) { + log.WithFields(log.Fields{ + "header": key, + "expected": expectedValue, + "actual": actualValue, + }).Debugf("Header mismatch") + return false + } + } + return true +} + +func (m *WebhookMatcher) matchQuery() bool { + if len(m.config.MatchQuery) == 0 { + return true + } + + query := m.req.URL.Query() + for key, expectedValue := range m.config.MatchQuery { + actualValue := query.Get(key) + if !m.compareValues(actualValue, expectedValue) { + log.WithFields(log.Fields{ + "query": key, + "expected": expectedValue, + "actual": actualValue, + }).Debugf("Query parameter mismatch") + return false + } + } + return true +} + +func (m *WebhookMatcher) matchPath() bool { + if m.config.MatchPath == "" { + return true + } + + jsonPath, expectedValue := m.parseMatchPath() + matcher, err := NewJSONMatcher(m.bodyBytes) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Debugf("Failed to create JSON matcher") + return false + } + + return m.matchPathValue(matcher, jsonPath, expectedValue) +} + +func (m *WebhookMatcher) parseMatchPath() (string, string) { + parts := strings.SplitN(m.config.MatchPath, "=", 2) + jsonPath := parts[0] + expectedValue := "" + if len(parts) == 2 { + expectedValue = parts[1] + } + return jsonPath, expectedValue +} + +func (m *WebhookMatcher) matchPathValue(matcher *JSONMatcher, jsonPath, expectedValue string) bool { + if expectedValue == "" { + _, err := matcher.ExtractValue(jsonPath) + return err == nil + } + + matches, err := matcher.MatchPath(jsonPath, expectedValue) + if err != nil { + log.WithFields(log.Fields{ + "jsonPath": jsonPath, + "error": err, + }).Debugf("Failed to match JSONPath") + return false + } + return matches +} + +func (m *WebhookMatcher) compareValues(actual, expected string) bool { + if strings.HasPrefix(expected, "regex:") { + pattern := strings.TrimPrefix(expected, "regex:") + matched, err := regexp.MatchString(pattern, actual) + if err != nil { + log.WithFields(log.Fields{ + "pattern": pattern, + "error": err, + }).Warnf("Invalid regex pattern") + return false + } + return matched + } + return actual == expected +} + +func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { + matcher, err := NewJSONMatcher(m.bodyBytes) + if err != nil { + return nil, err + } + + args := m.extractJSONPathValues(matcher) + m.addWebhookMetadata(args) + m.addWebhookHeaders(args) + + return args, nil +} + +func (m *WebhookMatcher) extractJSONPathValues(matcher *JSONMatcher) map[string]string { + args := make(map[string]string) + + for argName, jsonPath := range m.config.Extract { + value, err := matcher.ExtractValue(jsonPath) + if err != nil { + log.WithFields(log.Fields{ + "argName": argName, + "jsonPath": jsonPath, + "error": err, + }).Debugf("Failed to extract value") + continue + } + args[argName] = value + } + + return args +} + +func (m *WebhookMatcher) addWebhookMetadata(args map[string]string) { + args["webhook_method"] = m.req.Method + args["webhook_path"] = m.req.URL.Path + args["webhook_query"] = m.req.URL.RawQuery +} + +func (m *WebhookMatcher) addWebhookHeaders(args map[string]string) { + for key, values := range m.req.Header { + if len(values) > 0 { + args["webhook_header_"+strings.ToLower(key)] = values[0] + } + } +}