├── CHANGELOG.md ├── test ├── fixtures │ ├── malformed-config.yml │ ├── jsonschema │ │ ├── invalid.json │ │ └── charge.succeed.json │ ├── invalid_webhookx.yml │ ├── invalid-config.yml │ ├── wasm │ │ ├── noop.ts │ │ └── noop.wasm │ ├── license.json │ ├── plugins │ │ ├── outbound │ │ │ └── outbound_plugin.go │ │ ├── inbound │ │ │ └── inbound_plugin.go │ │ └── hello │ │ │ └── hello_plugin.go │ ├── mtls │ │ ├── client.crt │ │ ├── client-ca.crt │ │ ├── server-ca.crt │ │ ├── server.crt │ │ ├── client.key │ │ └── server.key │ └── webhookx.yml ├── plugins │ ├── testdata │ │ ├── index.wasm │ │ └── asconfig.json │ └── ginkgo_test.go ├── generate.go ├── secret-reference │ └── testdata │ │ ├── aws-secrets-not-found.yml │ │ ├── vault-kubernetes-response.json │ │ ├── aws-secrets.yml │ │ └── vault-secrets.yml ├── cmd │ ├── ginkgo_test.go │ ├── version_test.go │ ├── testdata │ │ └── dump.yml │ └── start_test.go ├── admin │ ├── ginkgo_test.go │ ├── license_test.go │ └── listen_test.go ├── proxy │ └── ginkgo_test.go ├── status │ └── ginkgo_test.go ├── vault-k8s-token.txt ├── helper │ ├── utils.go │ ├── http.go │ ├── config.go │ └── license.go ├── server.key ├── mocks │ └── bus.go ├── test.go ├── nginx.conf ├── README.md ├── server.crt ├── otel-collector-config.yml ├── init-vault.sh ├── metrics │ └── types.go ├── cache │ └── cache_test.go ├── cfg │ └── cfg_test.go └── tracing │ └── ginkgo_test.go ├── plugins ├── wasm │ ├── testdata │ │ ├── no_transform.wasm │ │ ├── no_transform.wat │ │ ├── rust │ │ │ ├── index.wasm │ │ │ └── Cargo.toml │ │ ├── tinygo │ │ │ └── index.wasm │ │ ├── transform_return_1.wasm │ │ ├── assemblyscript │ │ │ ├── index.wasm │ │ │ └── asconfig.json │ │ ├── Makefile │ │ └── transform_return_1.wat │ ├── context.go │ ├── types.go │ ├── function_utils.go │ ├── function_log.go │ ├── README.md │ └── function_request.go ├── jsonschema_validator │ └── jsonschema │ │ ├── validator.go │ │ ├── jsonschema.go │ │ └── jsonschema_test.go ├── function │ ├── function │ │ └── function.go │ ├── sdk │ │ ├── sdk_log.go │ │ ├── sdk_response.go │ │ ├── sdk.go │ │ ├── sdk_utils.go │ │ └── sdk_request.go │ └── plugin.go ├── webhookx_signature │ ├── plugin_test.go │ └── plugin.go ├── basic-auth │ └── plugin.go ├── plugins.go └── key-auth │ └── plugin.go ├── db ├── migrations │ ├── 4_plugins.down.sql │ ├── 3_create_attempt_details_table.down.sql │ ├── 11_event_unique_id.down.sql │ ├── 7_plugins_source_id.down.sql │ ├── migrations.go │ ├── 1_init.down.sql │ ├── 2_attempts.down.sql │ ├── 6_async_ingestion.down.sql │ ├── 10_ratelimit.down.sql │ ├── 10_ratelimit.up.sql │ ├── 11_event_unique_id.up.sql │ ├── 5_fix_attempt_details.up.sql │ ├── 7_plugins_source_id.up.sql │ ├── 8_metadata.down.sql │ ├── 5_fix_attempt_details.down.sql │ ├── 6_async_ingestion.up.sql │ ├── 2_attempts.up.sql │ ├── 8_metadata.up.sql │ ├── 1762423418_source_config.up.sql │ ├── 1762423418_source_config.down.sql │ ├── 3_create_attempt_details_table.up.sql │ ├── 4_plugins.up.sql │ ├── 9_timestamp.up.sql │ └── 9_timestamp.down.sql ├── query │ ├── order.go │ └── query.go ├── transaction │ └── transaction.go ├── entities │ ├── attempt_detail.go │ ├── workspace.go │ ├── event.go │ ├── schema.go │ ├── source.go │ ├── types.go │ └── endpoint.go ├── dao │ ├── source_dao.go │ ├── endpoint_dao.go │ ├── utils.go │ ├── workspace_dao.go │ ├── plugin_dao.go │ └── attempt_detail_dao.go └── errs │ └── error.go ├── config ├── testdata │ └── config-empty.yml ├── providers │ ├── provider.go │ └── env.go ├── modules │ ├── opentelemetry.go │ ├── base.go │ ├── access_log.go │ ├── status.go │ ├── admin.go │ ├── log.go │ ├── redis.go │ ├── tracing.go │ ├── database.go │ └── metrics.go └── types │ └── types.go ├── star.gif ├── codecov.yml ├── architecture.png ├── webhookx.go ├── examples └── wasm │ └── customize-headers │ ├── assemblyscript │ ├── README.md │ ├── index.wasm │ ├── tsconfig.json │ ├── asconfig.json │ └── package.json │ ├── rust │ ├── README.md │ ├── index.wasm │ └── Cargo.toml │ ├── go │ ├── index.wasm │ └── README.md │ ├── tinygo │ ├── index.wasm │ └── README.md │ └── README.md ├── status ├── utils.go ├── health │ └── healthcheck.go ├── status.go └── types.go ├── .dockerignore ├── proxy ├── router │ ├── route.go │ └── router.go └── middlewares │ ├── metrics.go │ └── recovery.go ├── utils ├── assert.go ├── uid.go ├── hash.go ├── uuid_test.go ├── http.go ├── color.go ├── conv.go ├── uuid.go ├── random_test.go ├── random.go ├── conv_test.go ├── utils.go ├── utils_test.go └── validate_test.go ├── cmd ├── main │ └── main.go ├── admin.go ├── utils.go ├── version.go ├── root_test.go ├── start.go └── root.go ├── pkg ├── types │ ├── types.go │ ├── time_test.go │ └── time.go ├── serializer │ ├── serializer.go │ ├── json.go │ └── msgpack.go ├── secret │ ├── provider │ │ ├── provider.go │ │ ├── vault │ │ │ └── authn.go │ │ └── aws │ │ │ └── aws.go │ └── reference │ │ └── reference.go ├── taskqueue │ ├── types.go │ └── queue.go ├── accesslog │ ├── utils.go │ ├── logger_json.go │ ├── logger.go │ ├── logger_text.go │ ├── middleware.go │ └── entry.go ├── ratelimiter │ ├── limiter.go │ └── redis.go ├── cache │ ├── cache.go │ └── redis.go ├── safe │ └── routine.go ├── store │ ├── store_test.go │ └── store.go ├── pool │ └── task.go ├── license │ ├── licenser_test.go │ ├── global.go │ ├── loader.go │ └── licenser.go ├── queue │ └── queue.go ├── loglimiter │ ├── limiter_test.go │ └── limiter.go ├── contextx │ └── context.go ├── reports │ ├── reports_test.go │ └── reports.go ├── plugin │ ├── base_test.go │ ├── registry.go │ ├── plugin.go │ └── base.go ├── errs │ └── error.go ├── http │ ├── middlewares │ │ └── middlewares.go │ └── response │ │ └── response.go ├── stats │ └── stats.go ├── log │ └── zap.go ├── schedule │ └── scheduler.go └── tracing │ └── tracing.go ├── .gitignore ├── admin ├── api │ ├── error.go │ ├── license.go │ ├── models.go │ ├── index.go │ ├── attempts.go │ └── middleware.go └── admin.go ├── .golangci.yml ├── api └── license │ ├── go.mod │ ├── verifier.go │ ├── signer.go │ └── go.sum ├── Dockerfile ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── release-test.yml │ └── release.yml ├── worker ├── retry │ ├── fixed.go │ ├── retry.go │ └── retry_test.go └── deliverer │ └── deliverer.go ├── dispatcher └── registration.go ├── .goreleaser.yml ├── constants ├── constants.go └── cache_key.go ├── eventbus └── types.go ├── Makefile └── webhookx.sample.yml /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/malformed-config.yml: -------------------------------------------------------------------------------- 1 | 👻 2 | -------------------------------------------------------------------------------- /plugins/wasm/testdata/no_transform.wasm: -------------------------------------------------------------------------------- 1 | asm -------------------------------------------------------------------------------- /plugins/wasm/testdata/no_transform.wat: -------------------------------------------------------------------------------- 1 | (module) 2 | -------------------------------------------------------------------------------- /test/fixtures/jsonschema/invalid.json: -------------------------------------------------------------------------------- 1 | "invalid jsonschema" -------------------------------------------------------------------------------- /test/fixtures/invalid_webhookx.yml: -------------------------------------------------------------------------------- 1 | webhookx is coolest! 2 | -------------------------------------------------------------------------------- /db/migrations/4_plugins.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "plugins"; 2 | -------------------------------------------------------------------------------- /test/fixtures/invalid-config.yml: -------------------------------------------------------------------------------- 1 | database: 2 | port: 65536 3 | -------------------------------------------------------------------------------- /config/testdata/config-empty.yml: -------------------------------------------------------------------------------- 1 | # this is an empty configuration 2 | -------------------------------------------------------------------------------- /star.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/star.gif -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "examples" 3 | - "test" 4 | - "pkg/envconfig" 5 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/architecture.png -------------------------------------------------------------------------------- /test/fixtures/wasm/noop.ts: -------------------------------------------------------------------------------- 1 | export function transform(): i32 { 2 | return 1 3 | } 4 | -------------------------------------------------------------------------------- /db/migrations/3_create_attempt_details_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "attempt_details"; 2 | -------------------------------------------------------------------------------- /test/fixtures/wasm/noop.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/test/fixtures/wasm/noop.wasm -------------------------------------------------------------------------------- /webhookx.go: -------------------------------------------------------------------------------- 1 | package webhookx 2 | 3 | import _ "embed" 4 | 5 | //go:embed openapi.yml 6 | var OpenAPI []byte 7 | -------------------------------------------------------------------------------- /db/migrations/11_event_unique_id.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "events" DROP COLUMN IF EXISTS "unique_id"; 2 | -------------------------------------------------------------------------------- /db/migrations/7_plugins_source_id.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "plugins" DROP COLUMN IF EXISTS "source_id"; 2 | -------------------------------------------------------------------------------- /config/providers/provider.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | type ConfigProvider interface { 4 | Load(cfg any) error 5 | } 6 | -------------------------------------------------------------------------------- /db/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import "embed" 4 | 5 | //go:embed *.sql 6 | var SQLs embed.FS 7 | -------------------------------------------------------------------------------- /test/plugins/testdata/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/test/plugins/testdata/index.wasm -------------------------------------------------------------------------------- /examples/wasm/customize-headers/assemblyscript/README.md: -------------------------------------------------------------------------------- 1 | # AssemblyScript example 2 | 3 | ``` 4 | $ npm run asbuild 5 | ``` 6 | -------------------------------------------------------------------------------- /status/utils.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | func BytesToMiB(b uint64) float64 { 4 | return float64(b) / (1024 * 1024) 5 | } 6 | -------------------------------------------------------------------------------- /plugins/wasm/testdata/rust/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/plugins/wasm/testdata/rust/index.wasm -------------------------------------------------------------------------------- /examples/wasm/customize-headers/rust/README.md: -------------------------------------------------------------------------------- 1 | # Rust example 2 | 3 | ``` 4 | $ cargo build --release --target wasm32-wasip1 5 | ``` 6 | -------------------------------------------------------------------------------- /plugins/wasm/testdata/tinygo/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/plugins/wasm/testdata/tinygo/index.wasm -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | 5 | .dockerignore 6 | .gitignore 7 | .env 8 | 9 | coverage.* 10 | webhookx 11 | -------------------------------------------------------------------------------- /test/generate.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | //go:generate mockgen --source ../pkg/taskqueue/queue.go --destination mocks/queue.go -package mocks 4 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/go/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/examples/wasm/customize-headers/go/index.wasm -------------------------------------------------------------------------------- /plugins/wasm/testdata/transform_return_1.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/plugins/wasm/testdata/transform_return_1.wasm -------------------------------------------------------------------------------- /proxy/router/route.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | type Route struct { 4 | Paths []string 5 | Methods []string 6 | Handler interface{} 7 | } 8 | -------------------------------------------------------------------------------- /utils/assert.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Must[T any](v T, err error) T { 4 | if err != nil { 5 | panic(err) 6 | } 7 | return v 8 | } 9 | -------------------------------------------------------------------------------- /utils/uid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/segmentio/ksuid" 4 | 5 | func KSUID() string { 6 | return ksuid.New().String() 7 | } 8 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/rust/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/examples/wasm/customize-headers/rust/index.wasm -------------------------------------------------------------------------------- /plugins/wasm/testdata/assemblyscript/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/plugins/wasm/testdata/assemblyscript/index.wasm -------------------------------------------------------------------------------- /cmd/main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/webhookx-io/webhookx/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/tinygo/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/examples/wasm/customize-headers/tinygo/index.wasm -------------------------------------------------------------------------------- /examples/wasm/customize-headers/assemblyscript/index.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhookx-io/webhookx/HEAD/examples/wasm/customize-headers/assemblyscript/index.wasm -------------------------------------------------------------------------------- /pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ErrorResponse struct { 4 | Message string `json:"message"` 5 | Error interface{} `json:"error,omitempty"` 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | coverage.* 4 | webhookx 5 | *.log 6 | dist/ 7 | test/output 8 | **/node_modules/ 9 | **/rust/target/ 10 | **/Cargo.lock 11 | *.test 12 | -------------------------------------------------------------------------------- /db/migrations/1_init.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "workspaces"; 2 | DROP TABLE IF EXISTS "endpoints"; 3 | DROP TABLE IF EXISTS "events"; 4 | DROP TABLE IF EXISTS "attempts"; 5 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/tinygo/README.md: -------------------------------------------------------------------------------- 1 | # TinyGo example 2 | 3 | ``` 4 | $ tinygo build -scheduler=none -target=wasip1 -buildmode=c-shared -o index.wasm index.go 5 | ``` 6 | -------------------------------------------------------------------------------- /admin/api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | var ( 4 | MsgNotFound = "Not found" 5 | MsgLicenseInvalid = "license missing or expired" 6 | MsgInavlidUUID = "Invalid uuid" 7 | ) 8 | -------------------------------------------------------------------------------- /db/migrations/2_attempts.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "attempts" DROP COLUMN IF EXISTS "trigger_mode"; 2 | ALTER TABLE IF EXISTS ONLY "attempts" DROP COLUMN IF EXISTS "exhausted"; 3 | -------------------------------------------------------------------------------- /db/migrations/6_async_ingestion.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "async"; 2 | ALTER TABLE IF EXISTS ONLY "events" DROP COLUMN IF EXISTS "ingested_at"; 3 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/assemblyscript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "assemblyscript/std/assembly.json", 4 | ], 5 | "include": [ 6 | "./**/*.ts" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /pkg/serializer/serializer.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | type Serializer interface { 4 | Serialize(val interface{}) ([]byte, error) 5 | Deserialize(b []byte, val interface{}) error 6 | } 7 | -------------------------------------------------------------------------------- /db/migrations/10_ratelimit.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "rate_limit"; 2 | ALTER TABLE IF EXISTS ONLY "endpoints" DROP COLUMN IF EXISTS "rate_limit"; 3 | 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | exclusions: 5 | paths: 6 | - test 7 | rules: 8 | - path: '(.+)_test\.go' 9 | linters: 10 | - errcheck 11 | -------------------------------------------------------------------------------- /config/modules/opentelemetry.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | type OtlpProtocol string 4 | 5 | const ( 6 | OtlpProtocolGRPC OtlpProtocol = "grpc" 7 | OtlpProtocolHTTP OtlpProtocol = "http/protobuf" 8 | ) 9 | -------------------------------------------------------------------------------- /db/migrations/10_ratelimit.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "rate_limit" JSONB; 2 | ALTER TABLE IF EXISTS ONLY "endpoints" ADD COLUMN IF NOT EXISTS "rate_limit" JSONB; 3 | -------------------------------------------------------------------------------- /db/migrations/11_event_unique_id.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "events" ADD COLUMN IF NOT EXISTS "unique_id" varchar(50); 2 | CREATE UNIQUE INDEX IF NOT EXISTS uk_events_unique_id ON events(unique_id); 3 | -------------------------------------------------------------------------------- /db/migrations/5_fix_attempt_details.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN request_body TYPE TEXT; 2 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN response_body TYPE TEXT; 3 | -------------------------------------------------------------------------------- /utils/hash.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | ) 7 | 8 | func Sha256(s string) string { 9 | h := sha256.Sum256([]byte(s)) 10 | return hex.EncodeToString(h[:]) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/secret/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Provider interface { 8 | GetValue(ctx context.Context, key string, properties map[string]string) (string, error) 9 | } 10 | -------------------------------------------------------------------------------- /status/health/healthcheck.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | type Status string 4 | 5 | const ( 6 | StatusUp = "UP" 7 | StatusDown = "DOWN" 8 | ) 9 | 10 | type Indicator struct { 11 | Name string 12 | Check func() error 13 | } 14 | -------------------------------------------------------------------------------- /utils/uuid_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestUUID(t *testing.T) { 10 | uuid := UUID() 11 | assert.True(t, IsValidUUID(uuid)) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/taskqueue/types.go: -------------------------------------------------------------------------------- 1 | package taskqueue 2 | 3 | type MessageData struct { 4 | EventID string `json:"event_id"` 5 | EndpointId string `json:"endpoint_id"` 6 | Attempt int `json:"attempt"` 7 | Event string `json:"event"` 8 | } 9 | -------------------------------------------------------------------------------- /db/migrations/7_plugins_source_id.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "plugins" ADD COLUMN IF NOT EXISTS "source_id" CHAR(27) REFERENCES "sources" ("id") ON DELETE CASCADE; 2 | CREATE UNIQUE INDEX uk_plugins_source_name ON plugins(source_id, name); 3 | -------------------------------------------------------------------------------- /test/secret-reference/testdata/aws-secrets-not-found.yml: -------------------------------------------------------------------------------- 1 | log: 2 | level: warn 3 | 4 | secret: 5 | aws: 6 | region: us-east-1 7 | url: http://localhost:4566 8 | 9 | database: 10 | host: "{secret://aws/webhookx/notfound}" 11 | -------------------------------------------------------------------------------- /db/migrations/8_metadata.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "metadata"; 2 | ALTER TABLE IF EXISTS ONLY "workspaces" DROP COLUMN IF EXISTS "metadata"; 3 | ALTER TABLE IF EXISTS ONLY "plugins" DROP COLUMN IF EXISTS "metadata"; 4 | 5 | -------------------------------------------------------------------------------- /db/migrations/5_fix_attempt_details.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN request_body TYPE JSONB USING request_body::JSONB; 2 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN response_body TYPE JSONB USING response_body::JSONB; 3 | -------------------------------------------------------------------------------- /test/cmd/ginkgo_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestCommand(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "CMD Suite") 13 | } 14 | -------------------------------------------------------------------------------- /test/admin/ginkgo_test.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestAdmin(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Admin Suite") 13 | } 14 | -------------------------------------------------------------------------------- /test/proxy/ginkgo_test.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestProxy(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Proxy Suite") 13 | } 14 | -------------------------------------------------------------------------------- /admin/api/license.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/webhookx-io/webhookx/pkg/license" 7 | ) 8 | 9 | func (api *API) GetLicense(w http.ResponseWriter, r *http.Request) { 10 | api.json(200, w, license.GetLicenser().License()) 11 | } 12 | -------------------------------------------------------------------------------- /test/status/ginkgo_test.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestStatus(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Status Suite") 13 | } 14 | -------------------------------------------------------------------------------- /test/plugins/ginkgo_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestPlugins(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Plugins Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/accesslog/utils.go: -------------------------------------------------------------------------------- 1 | package accesslog 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | func parseHostPort(hostport string) (string, string) { 8 | host, port, err := net.SplitHostPort(hostport) 9 | if err != nil { 10 | return hostport, "" 11 | } 12 | return host, port 13 | } 14 | -------------------------------------------------------------------------------- /db/query/order.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | type Sort = string 4 | 5 | const ( 6 | ASC Sort = "ASC" 7 | DESC Sort = "DESC" 8 | ) 9 | 10 | type Order struct { 11 | Column string 12 | Sort Sort 13 | } 14 | 15 | func (o Order) String() string { 16 | return o.Column + " " + o.Sort 17 | } 18 | -------------------------------------------------------------------------------- /test/vault-k8s-token.txt: -------------------------------------------------------------------------------- 1 | eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsid2ViaG9va3giXSwiZXhwIjoyNjIyNjQxNDkwLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoid2ViaG9va3giLCJ1aWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAifX0sIm5iZiI6MTc2MzYzMTY2NH0.xxx 2 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/go/README.md: -------------------------------------------------------------------------------- 1 | # Go example 2 | 3 | > Note: Go >= 1.24 is required to compile the example, as it uses features like go:wasmexport and string parameter that were introduced in Go 1.24. 4 | 5 | ``` 6 | GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o index.wasm 7 | ``` 8 | -------------------------------------------------------------------------------- /admin/api/models.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type Pagination[T any] struct { 4 | Total int64 `json:"total"` 5 | Data []T `json:"data"` 6 | } 7 | 8 | func NewPagination[T any](total int64, data []T) *Pagination[T] { 9 | return &Pagination[T]{ 10 | Total: total, 11 | Data: data, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /config/modules/base.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import "github.com/webhookx-io/webhookx/config/types" 4 | 5 | var _ types.Config = BaseConfig{} 6 | 7 | type BaseConfig struct{} 8 | 9 | func (c BaseConfig) PostProcess() error { return nil } 10 | func (c BaseConfig) Validate() error { return nil } 11 | -------------------------------------------------------------------------------- /utils/http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | func HeaderMap(header http.Header) map[string]string { 9 | headers := make(map[string]string) 10 | for name, values := range header { 11 | headers[name] = strings.Join(values, ",") 12 | } 13 | return headers 14 | } 15 | -------------------------------------------------------------------------------- /db/migrations/6_async_ingestion.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "async" BOOLEAN NOT NULL DEFAULT false; 2 | ALTER TABLE IF EXISTS ONLY "events" ADD COLUMN IF NOT EXISTS "ingested_at" TIMESTAMPTZ(3) DEFAULT (CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'); 3 | 4 | UPDATE "events" SET ingested_at = created_at; 5 | -------------------------------------------------------------------------------- /plugins/wasm/testdata/Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: 3 | cd assemblyscript && asc index.ts --target release 4 | cd rust && cargo build --release --target wasm32-unknown-unknown && mv target/wasm32-unknown-unknown/release/index.wasm . && cargo clean 5 | cd tinygo && tinygo build -scheduler=none -target=wasip1 -buildmode=c-shared -o index.wasm index.go 6 | -------------------------------------------------------------------------------- /plugins/wasm/testdata/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | name = "index" 9 | path = "index.rs" 10 | 11 | [dependencies] 12 | wee_alloc = "0.4.5" 13 | 14 | [profile.release] 15 | opt-level = "z" 16 | lto = true 17 | codegen-units = 1 18 | -------------------------------------------------------------------------------- /utils/color.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | ColorDarkGray = 90 9 | ) 10 | 11 | func Colorize(s interface{}, color int, enabled bool) string { 12 | if !enabled || color == 0 { 13 | return fmt.Sprintf("%s", s) 14 | } 15 | 16 | return fmt.Sprintf("\x1b[%dm%v\x1b[0m", color, s) 17 | } 18 | -------------------------------------------------------------------------------- /db/migrations/2_attempts.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "attempts" ADD COLUMN IF NOT EXISTS "trigger_mode" VARCHAR(10) NOT NULL DEFAULT 'INITIAL'; 2 | ALTER TABLE IF EXISTS ONLY "attempts" ADD COLUMN IF NOT EXISTS "exhausted" BOOLEAN NOT NULL DEFAULT false; 3 | 4 | UPDATE "attempts" SET trigger_mode = 'AUTOMATIC' where attempt_number != 1; 5 | -------------------------------------------------------------------------------- /utils/conv.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "time" 4 | 5 | func Pointer[T any](v T) *T { 6 | return &v 7 | } 8 | 9 | func PointerValue[T any](v *T) T { 10 | if v == nil { 11 | return *new(T) 12 | } 13 | return *v 14 | } 15 | 16 | func DurationS(seconds int64) time.Duration { 17 | return time.Duration(seconds) * time.Second 18 | } 19 | -------------------------------------------------------------------------------- /cmd/admin.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func newAdminCmd() *cobra.Command { 8 | admin := &cobra.Command{ 9 | Use: "admin", 10 | Short: "Admin commands", 11 | Long: ``, 12 | } 13 | 14 | admin.AddCommand(newAdminSyncCmd()) 15 | admin.AddCommand(newAdminDumpCmd()) 16 | 17 | return admin 18 | } 19 | -------------------------------------------------------------------------------- /db/migrations/8_metadata.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "metadata" JSONB NOT NULL DEFAULT '{}'::jsonb; 2 | ALTER TABLE IF EXISTS ONLY "workspaces" ADD COLUMN IF NOT EXISTS "metadata" JSONB NOT NULL DEFAULT '{}'::jsonb; 3 | ALTER TABLE IF EXISTS ONLY "plugins" ADD COLUMN IF NOT EXISTS "metadata" JSONB NOT NULL DEFAULT '{}'::jsonb; 4 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | name = "index" 9 | path = "index.rs" 10 | 11 | [dependencies] 12 | wee_alloc = "0.4.5" 13 | serde_json = "1.0" 14 | 15 | [profile.release] 16 | opt-level = "z" 17 | lto = true 18 | codegen-units = 1 19 | -------------------------------------------------------------------------------- /test/helper/utils.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import "os" 4 | 5 | func SetEnvironments(envs map[string]string) { 6 | for name, value := range envs { 7 | if err := os.Setenv(name, value); err != nil { 8 | panic(err) 9 | } 10 | } 11 | } 12 | 13 | func ClearEnvironments(envs map[string]string) { 14 | for k := range envs { 15 | os.Unsetenv(k) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /plugins/jsonschema_validator/jsonschema/validator.go: -------------------------------------------------------------------------------- 1 | package jsonschema 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type Validator interface { 8 | Validate(ctx *ValidatorContext) error 9 | } 10 | 11 | type ValidatorContext struct { 12 | HTTPRequest *HTTPRequest 13 | } 14 | 15 | type HTTPRequest struct { 16 | R *http.Request 17 | Data map[string]any 18 | } 19 | -------------------------------------------------------------------------------- /pkg/ratelimiter/limiter.go: -------------------------------------------------------------------------------- 1 | package ratelimiter 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Result struct { 9 | Allowed bool 10 | Remaining int 11 | Reset time.Duration 12 | RetryAfter time.Duration 13 | } 14 | 15 | type RateLimiter interface { 16 | Allow(ctx context.Context, key string, quota int, duration time.Duration) (Result, error) 17 | } 18 | -------------------------------------------------------------------------------- /test/plugins/testdata/asconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": { 3 | "release": { 4 | "outFile": "index.wasm", 5 | "sourceMap": false, 6 | "optimizeLevel": 3, 7 | "shrinkLevel": 0, 8 | "converge": false, 9 | "noAssert": false 10 | } 11 | }, 12 | "options": { 13 | "bindings": "esm", 14 | "use": "abort=abort_proc_exit" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/uuid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | 6 | uuid "github.com/satori/go.uuid" 7 | ) 8 | 9 | func UUID() string { 10 | return uuid.NewV4().String() 11 | } 12 | 13 | func UUIDShort() string { 14 | return strings.ReplaceAll(UUID(), "-", "") 15 | } 16 | 17 | func IsValidUUID(id string) bool { 18 | _, err := uuid.FromString(id) 19 | return err == nil 20 | } 21 | -------------------------------------------------------------------------------- /test/fixtures/license.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "00000000-0000-0000-0000-000000000000", 3 | "plan": "test", 4 | "customer": "test", 5 | "expired_at": "2099-12-31T00:00:00Z", 6 | "created_at": "1996-08-24T00:00:00Z", 7 | "version": "1", 8 | "signature": "51d58eecc5c15534693c025e5c591f4b202fc2d3361f4d47d033d5afc9480e5c1982b6eca3d7b462db01d63790dbf021c50ad2e422c86a3dfdbef68af19d2205" 9 | } 10 | -------------------------------------------------------------------------------- /utils/random_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRandomString(t *testing.T) { 10 | assert.Equal(t, "", RandomString(0)) 11 | assert.Equal(t, 1, len(RandomString(1))) 12 | assert.Equal(t, 32, len(RandomString(32))) 13 | assert.Panics(t, func() { RandomString(-1) }, "the code did not panic") 14 | } 15 | -------------------------------------------------------------------------------- /plugins/wasm/testdata/assemblyscript/asconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": { 3 | "release": { 4 | "outFile": "index.wasm", 5 | "sourceMap": false, 6 | "optimizeLevel": 3, 7 | "shrinkLevel": 0, 8 | "converge": false, 9 | "noAssert": false 10 | } 11 | }, 12 | "options": { 13 | "bindings": "esm", 14 | "use": "abort=abort_proc_exit" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pkg/serializer/json.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | var JSON JSONSerializer 8 | 9 | type JSONSerializer struct{} 10 | 11 | func (s JSONSerializer) Serialize(val interface{}) ([]byte, error) { 12 | return json.Marshal(val) 13 | } 14 | 15 | func (s JSONSerializer) Deserialize(b []byte, val interface{}) error { 16 | return json.Unmarshal(b, val) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Cache interface { 9 | Put(ctx context.Context, key string, val interface{}, expiration time.Duration) error 10 | Get(ctx context.Context, key string, val interface{}) (exist bool, err error) 11 | Remove(ctx context.Context, key string) error 12 | Exist(ctx context.Context, key string) (bool, error) 13 | } 14 | -------------------------------------------------------------------------------- /test/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MIGkAgEBBDADvHuVfl91yS0hJ3LV6bclHwvzK2PRiC51LyrCBEQkL4JqkNjUC3OR 6 | 8+LUDX6X1nCgBwYFK4EEACKhZANiAAQJgiQJ4WkX5lyHrB8sbI41KfLKvXqz5a6/ 7 | gBhTCIQrVmQcLCyJjraQkcM3CQP5VNOtUbcGnAf7KDp+Jhh9+VXjg6y1aCVP9erV 8 | lIsxzNX4p7ID5sMJd3BldGAUi3rX3OA= 9 | -----END EC PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /test/secret-reference/testdata/vault-kubernetes-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "authentication.k8s.io/v1", 3 | "kind": "TokenReview", 4 | "status": { 5 | "authenticated": true, 6 | "user": { 7 | "username": "system:serviceaccount:default:webhookx", 8 | "uid": "00000000-0000-0000-0000-000000000000" 9 | }, 10 | "audiences": [ 11 | "webhookx" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | var ANSWERS = map[string]bool{ 10 | "y": true, 11 | "yes": true, 12 | "n": false, 13 | "no": false, 14 | } 15 | 16 | func prompt(w io.Writer, q string) bool { 17 | _, _ = w.Write([]byte("> " + q + " [Y/N] ")) 18 | var answer string 19 | _, _ = fmt.Scan(&answer) 20 | return ANSWERS[strings.ToLower(answer)] 21 | } 22 | -------------------------------------------------------------------------------- /pkg/safe/routine.go: -------------------------------------------------------------------------------- 1 | package safe 2 | 3 | import ( 4 | "runtime" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | func Go(fn func()) { 10 | go func() { 11 | defer func() { 12 | if err := recover(); err != nil { 13 | buf := make([]byte, 2048) 14 | n := runtime.Stack(buf, false) 15 | buf = buf[:n] 16 | 17 | zap.S().Errorf("goroutine panic: %v\n %s", err, buf) 18 | } 19 | }() 20 | fn() 21 | }() 22 | } 23 | -------------------------------------------------------------------------------- /config/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "encoding/json" 4 | 5 | type Config interface { 6 | Validate() error 7 | PostProcess() error 8 | } 9 | 10 | type Map map[string]string 11 | 12 | func (m *Map) Decode(value string) error { 13 | return json.Unmarshal([]byte(value), m) 14 | } 15 | 16 | type Password string 17 | 18 | func (p Password) MarshalJSON() ([]byte, error) { 19 | return json.Marshal("******") 20 | } 21 | -------------------------------------------------------------------------------- /api/license/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/webhookx-io/webhookx/api/license 2 | 3 | go 1.25.4 4 | 5 | require github.com/satori/go.uuid v1.2.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | github.com/stretchr/testify v1.11.1 // indirect 11 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 12 | gopkg.in/yaml.v3 v3.0.1 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /db/transaction/transaction.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jmoiron/sqlx" 7 | ) 8 | 9 | type txContextKey struct{} 10 | 11 | func WithTx(ctx context.Context, tx *sqlx.Tx) context.Context { 12 | return context.WithValue(ctx, txContextKey{}, tx) 13 | } 14 | 15 | func FromContext(ctx context.Context) (*sqlx.Tx, bool) { 16 | value, ok := ctx.Value(txContextKey{}).(*sqlx.Tx) 17 | return value, ok 18 | } 19 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/assemblyscript/asconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@assemblyscript/wasi-shim/asconfig.json", 3 | "targets": { 4 | "release": { 5 | "outFile": "index.wasm", 6 | "optimizeLevel": 3, 7 | "shrinkLevel": 0, 8 | "converge": false, 9 | "noAssert": false 10 | } 11 | }, 12 | "options": { 13 | "bindings": "esm", 14 | "use": "abort=abort_proc_exit" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/webhookx-io/webhookx/test/helper" 7 | ) 8 | 9 | var _ = Describe("version", Ordered, func() { 10 | It("outputs version", func() { 11 | output, err := helper.ExecAppCommand("version") 12 | assert.Nil(GinkgoT(), err) 13 | assert.Equal(GinkgoT(), "WebhookX dev (unknown)\n", output) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.4 AS build-env 2 | 3 | WORKDIR /go/src/webhookx-io/webhookx 4 | 5 | COPY go.mod /go/src/webhookx-io/webhookx 6 | COPY go.sum /go/src/webhookx-io/webhookx 7 | RUN go mod download 8 | 9 | COPY . . 10 | RUN make build 11 | 12 | FROM alpine:3.22 13 | 14 | COPY --from=build-env /go/src/webhookx-io/webhookx/webhookx /usr/local/bin 15 | 16 | EXPOSE 9600 17 | EXPOSE 9601 18 | EXPOSE 9602 19 | 20 | 21 | CMD ["webhookx", "start"] 22 | -------------------------------------------------------------------------------- /db/entities/attempt_detail.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | type AttemptDetail struct { 4 | ID string `json:"id" db:"id"` 5 | RequestHeaders Headers `json:"request_headers" db:"request_headers"` 6 | RequestBody *string `json:"request_body" db:"request_body"` 7 | ResponseHeaders *Headers `json:"response_headers" db:"response_headers"` 8 | ResponseBody *string `json:"response_body" db:"response_body"` 9 | 10 | BaseModel 11 | } 12 | -------------------------------------------------------------------------------- /pkg/store/store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test(t *testing.T) { 10 | v, ok := Get("key") 11 | assert.False(t, ok) 12 | assert.Nil(t, v) 13 | 14 | Set("key", "value") 15 | v, ok = Get("key") 16 | assert.True(t, ok) 17 | assert.Equal(t, "value", v) 18 | 19 | Remove("key") 20 | v, ok = Get("key") 21 | assert.False(t, ok) 22 | assert.Nil(t, v) 23 | } 24 | -------------------------------------------------------------------------------- /plugins/wasm/context.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/webhookx-io/webhookx/pkg/plugin" 7 | ) 8 | 9 | type key struct{} 10 | 11 | func withContext(ctx context.Context, val *plugin.Outbound) context.Context { 12 | return context.WithValue(ctx, key{}, val) 13 | } 14 | 15 | func fromContext(ctx context.Context) (*plugin.Outbound, bool) { 16 | value, ok := ctx.Value(key{}).(*plugin.Outbound) 17 | return value, ok 18 | } 19 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/webhookx-io/webhookx/config" 6 | ) 7 | 8 | func newVersionCmd() *cobra.Command { 9 | return &cobra.Command{ 10 | Use: "version", 11 | Short: "Print the version", 12 | Long: `Print the version with a short commit hash.`, 13 | Run: func(cmd *cobra.Command, args []string) { 14 | cmd.Printf("WebhookX %s (%s)\n", config.VERSION, config.COMMIT) 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/pool/task.go: -------------------------------------------------------------------------------- 1 | package pool 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | type Task interface { 9 | Execute() 10 | } 11 | 12 | type task struct { 13 | fn func() 14 | } 15 | 16 | func (t *task) Execute() { 17 | defer func() { 18 | if e := recover(); e != nil { 19 | buf := make([]byte, 2048) 20 | n := runtime.Stack(buf, false) 21 | buf = buf[:n] 22 | fmt.Printf("panic recovered: %v\n %s\n", e, buf) 23 | } 24 | }() 25 | t.fn() 26 | } 27 | -------------------------------------------------------------------------------- /plugins/wasm/types.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | type Status int32 4 | 5 | const ( 6 | StatusOk Status = 0 7 | StatusInternalFailure Status = 1 8 | StatusBadArgument Status = 2 9 | StatusInvalidMemoryAccess Status = 3 10 | StatusInvalidJSON Status = 11 11 | ) 12 | 13 | type LogLevel int32 14 | 15 | const ( 16 | LogLveDebug LogLevel = 0 17 | LogLveInfo LogLevel = 1 18 | LogLveWarn LogLevel = 2 19 | LogLveError LogLevel = 3 20 | ) 21 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/assemblyscript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assemblyscript", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "asbuild:release": "asc index.ts --target release", 7 | "asbuild": "npm run asbuild:release" 8 | }, 9 | "devDependencies": { 10 | "@assemblyscript/wasi-shim": "^0.1.0", 11 | "assemblyscript": "^0.27.35" 12 | }, 13 | "dependencies": { 14 | "assemblyscript-json": "^1.1.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/helper/http.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func StartHttpServer(handler http.HandlerFunc, addr string) *http.Server { 10 | s := &http.Server{ 11 | Handler: handler, 12 | Addr: addr, 13 | } 14 | go func() { 15 | if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 16 | panic(fmt.Errorf("failed to start HTTP server: %s", err.Error())) 17 | } 18 | }() 19 | return s 20 | } 21 | -------------------------------------------------------------------------------- /utils/random.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 9 | 10 | func RandomString(n int) string { 11 | if n < 0 { 12 | panic("invalid args n") 13 | } 14 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 15 | result := make([]byte, n) 16 | for i := range result { 17 | result[i] = charset[r.Intn(len(charset))] 18 | } 19 | return string(result) 20 | } 21 | -------------------------------------------------------------------------------- /test/mocks/bus.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import "github.com/webhookx-io/webhookx/eventbus" 4 | 5 | type MockBus struct{} 6 | 7 | func (m MockBus) ClusteringBroadcast(event string, data interface{}) error { 8 | return nil 9 | } 10 | 11 | func (m MockBus) ClusteringSubscribe(channel string, fn func(data []byte)) { 12 | } 13 | 14 | func (m MockBus) Broadcast(channel string, data interface{}) { 15 | } 16 | 17 | func (m MockBus) Subscribe(channel string, cb eventbus.Callback) { 18 | } 19 | -------------------------------------------------------------------------------- /test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | 7 | "github.com/stretchr/testify/suite" 8 | ) 9 | 10 | var dir string // test dir 11 | 12 | func init() { 13 | _, filename, _, _ := runtime.Caller(0) 14 | dir = filepath.Dir(filename) 15 | } 16 | 17 | func FilePath(filename string) string { 18 | return filepath.Join(dir, filename) 19 | } 20 | 21 | type BasicSuite struct { 22 | suite.Suite 23 | } 24 | 25 | func (s *BasicSuite) SetupSuite() { 26 | 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/jsonschema/charge.succeed.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "type": "string" 6 | }, 7 | "amount": { 8 | "type": "integer", 9 | "minimum": 1 10 | }, 11 | "currency": { 12 | "type": "string", 13 | "minLength": 3, 14 | "maxLength": 6 15 | } 16 | }, 17 | "required": [ 18 | "id", 19 | "amount", 20 | "currency" 21 | ] 22 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | 8 | updates: 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /plugins/wasm/testdata/transform_return_1.wat: -------------------------------------------------------------------------------- 1 | (module 2 | (type $0 (func (result i32))) 3 | (global $~lib/memory/__data_end i32 (i32.const 8)) 4 | (global $~lib/memory/__stack_pointer (mut i32) (i32.const 32776)) 5 | (global $~lib/memory/__heap_base i32 (i32.const 32776)) 6 | (memory $0 0) 7 | (table $0 1 1 funcref) 8 | (elem $0 (i32.const 1)) 9 | (export "transform" (func $index/transform)) 10 | (export "memory" (memory $0)) 11 | (func $index/transform (result i32) 12 | i32.const 0 13 | return 14 | ) 15 | ) 16 | -------------------------------------------------------------------------------- /api/license/verifier.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "encoding/hex" 6 | ) 7 | 8 | type Verifier struct { 9 | PublicKey []byte 10 | } 11 | 12 | func NewVerifier(publicKey string) (*Verifier, error) { 13 | key, err := hex.DecodeString(publicKey) 14 | if err != nil { 15 | return nil, err 16 | } 17 | return &Verifier{ 18 | PublicKey: key, 19 | }, nil 20 | } 21 | 22 | func (v *Verifier) Verify(message, signature []byte) bool { 23 | return ed25519.Verify(v.PublicKey, message, signature) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/license/licenser_test.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/webhookx-io/webhookx/api/license" 9 | ) 10 | 11 | func Test(t *testing.T) { 12 | license := license.New() 13 | license.Plan = "enterprise" 14 | licenser := NewLicenser(license) 15 | assert.Equal(t, true, licenser.Allow("secret")) 16 | 17 | license.ExpiredAt = time.Now() 18 | assert.Equal(t, false, licenser.Allow("secret")) // should be false when license is expired 19 | } 20 | -------------------------------------------------------------------------------- /test/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | 3 | http { 4 | server { 5 | listen 443 ssl; 6 | server_name localhost; 7 | 8 | ssl_certificate /etc/nginx/certs/server.crt; 9 | ssl_certificate_key /etc/nginx/certs/server.key; 10 | 11 | location / { 12 | proxy_pass http://httpbin:80; 13 | proxy_set_header Host $host; 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/license/signer.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "encoding/hex" 6 | ) 7 | 8 | type Signer struct { 9 | PrivateKey []byte 10 | } 11 | 12 | func NewSigner(privateKey string) (*Signer, error) { 13 | key, err := hex.DecodeString(privateKey) 14 | if err != nil { 15 | return nil, err 16 | } 17 | return &Signer{ 18 | PrivateKey: key, 19 | }, nil 20 | } 21 | 22 | func (s *Signer) Sign(message string) string { 23 | signature := ed25519.Sign(s.PrivateKey, []byte(message)) 24 | return hex.EncodeToString(signature) 25 | } 26 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func executeCommand(root *cobra.Command, args ...string) (output string, err error) { 12 | buf := new(bytes.Buffer) 13 | root.SetOut(buf) 14 | root.SetErr(buf) 15 | root.SetArgs(args) 16 | 17 | _, err = root.ExecuteC() 18 | return buf.String(), err 19 | } 20 | 21 | func TestCMD(t *testing.T) { 22 | output, err := executeCommand(NewRootCmd(), "") 23 | assert.Nil(t, err) 24 | assert.NotNil(t, output) 25 | } 26 | -------------------------------------------------------------------------------- /db/entities/workspace.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "github.com/webhookx-io/webhookx/pkg/types" 5 | ) 6 | 7 | type Workspace struct { 8 | ID string `json:"id" db:"id"` 9 | Name *string `json:"name" db:"name"` 10 | Description *string `json:"description" db:"description"` 11 | Metadata Metadata `json:"metadata" db:"metadata"` 12 | 13 | CreatedAt types.Time `db:"created_at" json:"created_at"` 14 | UpdatedAt types.Time `db:"updated_at" json:"updated_at"` 15 | } 16 | 17 | func (m *Workspace) SchemaName() string { 18 | return "Workspace" 19 | } 20 | -------------------------------------------------------------------------------- /pkg/queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Message struct { 9 | Value []byte 10 | Time time.Time 11 | WorkspaceID string 12 | } 13 | 14 | type HandlerFunc func(ctx context.Context, messages []*Message) error 15 | 16 | type Queue interface { 17 | Producer 18 | Consumer 19 | Stats() map[string]interface{} 20 | } 21 | 22 | type Producer interface { 23 | Enqueue(ctx context.Context, message *Message) error 24 | } 25 | 26 | type Consumer interface { 27 | StartListen(ctx context.Context, handler HandlerFunc) 28 | } 29 | -------------------------------------------------------------------------------- /proxy/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "slices" 6 | ) 7 | 8 | type Router struct { 9 | routes []*Route 10 | } 11 | 12 | func NewRouter(routes []*Route) *Router { 13 | router := &Router{ 14 | routes: routes, 15 | } 16 | return router 17 | } 18 | 19 | func (r *Router) Execute(req *http.Request) interface{} { 20 | path := req.URL.Path 21 | method := req.Method 22 | for _, route := range r.routes { 23 | if route.Paths[0] == path && slices.Contains(route.Methods, method) { 24 | return route.Handler 25 | } 26 | } 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '**/*.md' 7 | push: 8 | branches: 9 | - main 10 | paths-ignore: 11 | - '**/*.md' 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version-file: go.mod 22 | 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v8 25 | with: 26 | version: v2.4 27 | only-new-issues: true 28 | -------------------------------------------------------------------------------- /pkg/loglimiter/limiter_test.go: -------------------------------------------------------------------------------- 1 | package loglimiter 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test(t *testing.T) { 11 | limiter := NewLimiter(time.Millisecond * 100) 12 | 13 | n := 0 14 | ticker := time.NewTicker(time.Millisecond * 10) 15 | timeout := time.NewTimer(time.Millisecond * 1001) 16 | defer timeout.Stop() 17 | for { 18 | select { 19 | case <-ticker.C: 20 | if limiter.Allow("key") { 21 | n++ 22 | } 23 | case <-timeout.C: 24 | assert.Equal(t, 10, n) 25 | return 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /plugins/function/function/function.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/webhookx-io/webhookx/plugins/function/function/javascript" 7 | "github.com/webhookx-io/webhookx/plugins/function/sdk" 8 | ) 9 | 10 | type Function interface { 11 | Execute(ctx *sdk.ExecutionContext) (sdk.ExecutionResult, error) 12 | } 13 | 14 | func New(language string, script string) Function { 15 | if language == "javascript" { 16 | return javascript.New(script, javascript.Options{ 17 | Timeout: time.Second, 18 | }) 19 | } 20 | panic("unsupported language: " + language) 21 | } 22 | -------------------------------------------------------------------------------- /admin/api/index.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/webhookx-io/webhookx/config" 7 | ) 8 | 9 | type IndexResponse struct { 10 | Version string `json:"version"` 11 | Message string `json:"message"` 12 | Configuration *config.Config `json:"configuration"` 13 | } 14 | 15 | func (api *API) Index(w http.ResponseWriter, r *http.Request) { 16 | var response IndexResponse 17 | 18 | response.Version = config.VERSION 19 | response.Message = "Welcome to WebhookX" 20 | response.Configuration = api.cfg 21 | 22 | api.json(200, w, response) 23 | } 24 | -------------------------------------------------------------------------------- /db/migrations/1762423418_source_config.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "type" varchar(20); 2 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "config" JSONB NOT NULL DEFAULT '{}'::jsonb; 3 | 4 | UPDATE sources SET "type" = 'http', "config" = jsonb_build_object('http', jsonb_build_object('methods', methods, 'path', path, 'response', response)); 5 | 6 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "path"; 7 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "methods"; 8 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "response"; 9 | -------------------------------------------------------------------------------- /pkg/accesslog/logger_json.go: -------------------------------------------------------------------------------- 1 | package accesslog 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/rs/zerolog" 7 | ) 8 | 9 | type JsonLogger struct { 10 | logger *zerolog.Logger 11 | } 12 | 13 | func NewJsonLogger(name string, writer io.Writer) *JsonLogger { 14 | zerolog.TimeFieldFormat = "2006/01/02 15:04:05.000" 15 | zerolog.TimestampFieldName = "ts" 16 | logger := zerolog.New(writer).With().Str("name", name).Logger() 17 | 18 | return &JsonLogger{ 19 | logger: &logger, 20 | } 21 | } 22 | 23 | func (l *JsonLogger) Log(entry *Entry) { 24 | l.logger.Log().Timestamp().EmbedObject(entry).Send() 25 | } 26 | -------------------------------------------------------------------------------- /pkg/contextx/context.go: -------------------------------------------------------------------------------- 1 | package contextx 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type key struct{} 8 | 9 | type Context struct { 10 | WorkspaceID string 11 | WorkspaceName string 12 | } 13 | 14 | func WithContext(ctx context.Context, v *Context) context.Context { 15 | return context.WithValue(ctx, key{}, v) 16 | } 17 | 18 | func FromContext(ctx context.Context) (*Context, bool) { 19 | value, ok := ctx.Value(key{}).(*Context) 20 | return value, ok 21 | } 22 | 23 | func GetWorkspaceID(ctx context.Context) string { 24 | if w, ok := FromContext(ctx); ok { 25 | return w.WorkspaceID 26 | } 27 | return "" 28 | } 29 | -------------------------------------------------------------------------------- /pkg/license/global.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | "sync/atomic" 5 | 6 | "github.com/webhookx-io/webhookx/api/license" 7 | ) 8 | 9 | var ( 10 | globalLicenser = defaultGlobalLicenser() 11 | ) 12 | 13 | type licenseHolder struct{ value Licenser } 14 | 15 | func defaultGlobalLicenser() *atomic.Value { 16 | v := &atomic.Value{} 17 | v.Store(licenseHolder{NewLicenser(license.NewFree())}) 18 | return v 19 | } 20 | 21 | func GetLicenser() Licenser { 22 | return globalLicenser.Load().(licenseHolder).value 23 | } 24 | 25 | func SetLicenser(licenser Licenser) { 26 | globalLicenser.Store(licenseHolder{licenser}) 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/plugins/outbound/outbound_plugin.go: -------------------------------------------------------------------------------- 1 | package outbound 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/webhookx-io/webhookx/pkg/plugin" 8 | ) 9 | 10 | type Config struct { 11 | } 12 | 13 | func (c Config) Schema() *openapi3.Schema { 14 | return openapi3.NewObjectSchema() 15 | } 16 | 17 | type OutboundPlugin struct { 18 | plugin.BasePlugin[Config] 19 | } 20 | 21 | func (p *OutboundPlugin) Name() string { 22 | return "outbound" 23 | } 24 | 25 | func (p *OutboundPlugin) ExecuteOutbound(ctx context.Context, outbound *plugin.Outbound) error { 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /config/modules/access_log.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | ) 7 | 8 | type AccessLogConfig struct { 9 | BaseConfig 10 | Enabled bool `yaml:"enabled" json:"enabled" default:"true"` 11 | Format LogFormat `yaml:"format" json:"format" default:"text"` 12 | Colored bool `yaml:"colored" json:"colored" default:"true"` 13 | File string `yaml:"file" json:"file"` 14 | } 15 | 16 | func (cfg AccessLogConfig) Validate() error { 17 | if !slices.Contains([]LogFormat{LogFormatText, LogFormatJson}, cfg.Format) { 18 | return fmt.Errorf("invalid format: %s", cfg.Format) 19 | } 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /db/migrations/1762423418_source_config.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "path" TEXT; 2 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "methods" TEXT[]; 3 | ALTER TABLE IF EXISTS ONLY "sources" ADD COLUMN IF NOT EXISTS "response" JSONB; 4 | 5 | UPDATE sources SET 6 | "path" = (config->'http'->>'path'), 7 | "methods" = ARRAY(SELECT jsonb_array_elements_text(config->'http'->'methods')), 8 | "response" = (config->'http'->'response'); 9 | 10 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "type"; 11 | ALTER TABLE IF EXISTS ONLY "sources" DROP COLUMN IF EXISTS "config"; 12 | -------------------------------------------------------------------------------- /pkg/loglimiter/limiter.go: -------------------------------------------------------------------------------- 1 | package loglimiter 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type Limiter struct { 9 | mux sync.Mutex 10 | window time.Duration 11 | logs map[string]time.Time 12 | } 13 | 14 | func NewLimiter(window time.Duration) *Limiter { 15 | return &Limiter{ 16 | window: window, 17 | logs: make(map[string]time.Time), 18 | } 19 | } 20 | 21 | func (l *Limiter) Allow(key string) bool { 22 | l.mux.Lock() 23 | defer l.mux.Unlock() 24 | 25 | now := time.Now() 26 | last := l.logs[key] 27 | if now.Sub(last) > l.window { 28 | l.logs[key] = now 29 | return true 30 | } 31 | 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /test/fixtures/plugins/inbound/inbound_plugin.go: -------------------------------------------------------------------------------- 1 | package inbound 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/webhookx-io/webhookx/pkg/plugin" 8 | ) 9 | 10 | type Config struct { 11 | } 12 | 13 | func (c Config) Schema() *openapi3.Schema { 14 | return openapi3.NewObjectSchema() 15 | } 16 | 17 | type InboundPlugin struct { 18 | plugin.BasePlugin[Config] 19 | } 20 | 21 | func (p *InboundPlugin) Name() string { 22 | return "inbound" 23 | } 24 | 25 | func (p *InboundPlugin) ExecuteInbound(ctx context.Context, inbound *plugin.Inbound) (res plugin.InboundResult, err error) { 26 | return 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/release-test.yml: -------------------------------------------------------------------------------- 1 | name: Release Test 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Release"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | test-docker-compose: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: test docker compose 17 | run: | 18 | docker compose up -d 19 | sleep 10 20 | STATUS_CODE=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:9601) 21 | if [ "$STATUS_CODE" -ne 200 ]; then 22 | echo "API failed with status code $STATUS_CODE" 23 | exit 1 24 | fi 25 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | 4 | 1. starts dependencies 5 | 6 | ```shell 7 | make deps 8 | ``` 9 | 10 | 2. runs integration tests 11 | 12 | ```shell 13 | make test-integration 14 | ``` 15 | 16 | 17 | # How-To 18 | 19 | #### How to run specific tests? 20 | 21 | `FIt`, `FContext`. 22 | 23 | ``` 24 | Context("some specs you're debugging", func() { 25 | It("might be failing", func() { ... }) 26 | FIt("might also be failing", func() { ... }) 27 | }) 28 | 29 | ``` 30 | 31 | ``` 32 | ginkgo 33 | ``` 34 | 35 | This will run only test "might also be failing", skip the rest. 36 | 37 | See https://onsi.github.io/ginkgo/#focused-specs. 38 | -------------------------------------------------------------------------------- /db/migrations/3_create_attempt_details_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "attempt_details" ( 2 | "id" CHAR(27) PRIMARY KEY REFERENCES "attempts" ("id") ON DELETE CASCADE, 3 | 4 | "request_headers" JSONB, 5 | "request_body" JSONB, 6 | "response_headers" JSONB, 7 | "response_body" JSONB, 8 | 9 | "ws_id" CHAR(27), 10 | "created_at" TIMESTAMPTZ(3) DEFAULT (CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'), 11 | "updated_at" TIMESTAMPTZ(3) DEFAULT (CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC') 12 | ); 13 | 14 | CREATE INDEX idx_attempt_details_ws_id ON attempt_details (ws_id); 15 | -------------------------------------------------------------------------------- /test/helper/config.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "github.com/webhookx-io/webhookx/config" 5 | ) 6 | 7 | type LoadConfigOptions struct { 8 | Envs map[string]string 9 | File string 10 | ExcludeEnv bool 11 | } 12 | 13 | func LoadConfig(opts LoadConfigOptions) (*config.Config, error) { 14 | cfg := config.New() 15 | 16 | reset := SetEnvs(opts.Envs) 17 | defer reset() 18 | 19 | loader := config.NewLoader(cfg). 20 | WithFilename(opts.File) 21 | 22 | if !opts.ExcludeEnv { 23 | loader.WithEnvPrefix("WEBHOOKX") 24 | } 25 | 26 | if err := loader.Load(); err != nil { 27 | return nil, err 28 | } 29 | 30 | return cfg, nil 31 | } 32 | -------------------------------------------------------------------------------- /db/migrations/4_plugins.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "plugins" ( 2 | "id" CHAR(27) PRIMARY KEY, 3 | "endpoint_id" CHAR(27) REFERENCES "endpoints" ("id") ON DELETE CASCADE, 4 | "name" TEXT, 5 | "enabled" BOOLEAN NOT NULL DEFAULT TRUE, 6 | "config" JSONB NOT NULL DEFAULT '{}'::jsonb, 7 | "ws_id" CHAR(27), 8 | "created_at" TIMESTAMPTZ(3) DEFAULT (CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'), 9 | "updated_at" TIMESTAMPTZ(3) DEFAULT (CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC') 10 | ); 11 | 12 | CREATE INDEX idx_plugins_ws_id ON plugins (ws_id); 13 | CREATE UNIQUE INDEX uk_plugins_ws_name ON plugins(endpoint_id, name); 14 | -------------------------------------------------------------------------------- /pkg/reports/reports_test.go: -------------------------------------------------------------------------------- 1 | package reports 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSend(t *testing.T) { 12 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | w.WriteHeader(http.StatusNotFound) 14 | })) 15 | defer server.Close() 16 | err := send(server.URL) 17 | assert.NotNil(t, err) 18 | assert.Equal(t, "HTTP status 404", err.Error()) 19 | 20 | err = send("http://localhost:80") 21 | assert.NotNil(t, err) 22 | assert.Equal(t, "Post \"http://localhost:80\": dial tcp [::1]:80: connect: connection refused", err.Error()) 23 | } 24 | -------------------------------------------------------------------------------- /worker/retry/fixed.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import "time" 4 | 5 | type FixedStrategyRetry struct { 6 | fixedDelaySeconds []int64 7 | } 8 | 9 | func newFixedStrategyRetry() *FixedStrategyRetry { 10 | return &FixedStrategyRetry{} 11 | } 12 | 13 | func WithFixedDelay(fixedDelaySeconds []int64) Option { 14 | return func(r Retry) { 15 | retry := r.(*FixedStrategyRetry) 16 | retry.fixedDelaySeconds = fixedDelaySeconds 17 | } 18 | } 19 | 20 | func (r *FixedStrategyRetry) NextDelay(attempts int) time.Duration { 21 | if attempts > len(r.fixedDelaySeconds) { 22 | return Stop 23 | } 24 | seconds := r.fixedDelaySeconds[attempts-1] 25 | return time.Duration(seconds) * time.Second 26 | } 27 | -------------------------------------------------------------------------------- /db/dao/source_dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/webhookx-io/webhookx/constants" 6 | "github.com/webhookx-io/webhookx/db/entities" 7 | "github.com/webhookx-io/webhookx/eventbus" 8 | ) 9 | 10 | type sourceDAO struct { 11 | *DAO[entities.Source] 12 | } 13 | 14 | func NewSourceDAO(db *sqlx.DB, bus *eventbus.EventBus, workspace bool) SourceDAO { 15 | opts := Options{ 16 | Table: "sources", 17 | EntityName: "source", 18 | Workspace: workspace, 19 | CachePropagate: true, 20 | CacheName: constants.SourceCacheKey.Name, 21 | } 22 | return &sourceDAO{ 23 | DAO: NewDAO[entities.Source](db, bus, opts), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /plugins/function/sdk/sdk_log.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | type LogSDK struct{} 9 | 10 | func NewLogSDK() *LogSDK { 11 | return &LogSDK{} 12 | } 13 | 14 | func log(level zapcore.Level, message string) { 15 | zap.S().Log(level, message) 16 | } 17 | 18 | func (m *LogSDK) Debug(message string) { 19 | log(zapcore.DebugLevel, message) 20 | } 21 | 22 | func (m *LogSDK) Info(message string) { 23 | log(zapcore.InfoLevel, message) 24 | } 25 | 26 | func (m *LogSDK) Warn(message string) { 27 | log(zapcore.WarnLevel, message) 28 | } 29 | 30 | func (m *LogSDK) Error(message string) { 31 | log(zapcore.ErrorLevel, message) 32 | } 33 | -------------------------------------------------------------------------------- /dispatcher/registration.go: -------------------------------------------------------------------------------- 1 | package dispatcher 2 | 3 | import "github.com/webhookx-io/webhookx/db/entities" 4 | 5 | type Registration struct { 6 | static map[string][]*entities.Endpoint 7 | } 8 | 9 | func NewRegistration(endpoints []*entities.Endpoint) *Registration { 10 | r := &Registration{ 11 | static: make(map[string][]*entities.Endpoint), 12 | } 13 | 14 | for _, endpoint := range endpoints { 15 | for _, event := range endpoint.Events { 16 | r.static[event] = append(r.static[event], endpoint) 17 | } 18 | } 19 | return r 20 | } 21 | 22 | func (r *Registration) LookUp(event *entities.Event) []*entities.Endpoint { 23 | matched := r.static[event.EventType] 24 | return matched 25 | } 26 | -------------------------------------------------------------------------------- /db/dao/endpoint_dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/webhookx-io/webhookx/constants" 6 | "github.com/webhookx-io/webhookx/db/entities" 7 | "github.com/webhookx-io/webhookx/eventbus" 8 | ) 9 | 10 | type endpointDAO struct { 11 | *DAO[entities.Endpoint] 12 | } 13 | 14 | func NewEndpointDAO(db *sqlx.DB, bus *eventbus.EventBus, workspace bool) EndpointDAO { 15 | opts := Options{ 16 | Table: "endpoints", 17 | EntityName: "endpoint", 18 | Workspace: workspace, 19 | CachePropagate: true, 20 | CacheName: constants.EndpointCacheKey.Name, 21 | } 22 | return &endpointDAO{ 23 | DAO: NewDAO[entities.Endpoint](db, bus, opts), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /plugins/function/sdk/sdk_response.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import "encoding/json" 4 | 5 | type ResponseSDK struct { 6 | opts *Options 7 | } 8 | 9 | func NewResponseSDK(opts *Options) *ResponseSDK { 10 | return &ResponseSDK{ 11 | opts: opts, 12 | } 13 | } 14 | 15 | func (sdk *ResponseSDK) Exit(status int, headers map[string]string, body interface{}) { 16 | response := &HTTPResponse{ 17 | Code: status, 18 | Headers: headers, 19 | } 20 | switch v := body.(type) { 21 | case string: 22 | response.Body = v 23 | default: 24 | bytes, err := json.Marshal(v) 25 | if err != nil { 26 | panic(err) 27 | } 28 | response.Body = string(bytes) 29 | } 30 | sdk.opts.Result.HTTPResponse = response 31 | } 32 | -------------------------------------------------------------------------------- /plugins/wasm/function_utils.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/tetratelabs/wazero/api" 8 | ) 9 | 10 | func writeString(ctx context.Context, memory api.Memory, allocate api.Function, str string) (uint32, error) { 11 | ptr, err := allocate.Call(ctx, uint64(len(str))) 12 | if err != nil || len(ptr) == 0 { 13 | return 0, err 14 | } 15 | 16 | p := uint32(ptr[0]) 17 | if !memory.WriteString(p, str) { 18 | return 0, errors.New("failed to write string to memory") 19 | } 20 | 21 | return p, nil 22 | } 23 | 24 | func readString(memory api.Memory, ptr uint32, length uint32) (string, bool) { 25 | buf, ok := memory.Read(ptr, length) 26 | return string(buf), ok 27 | } 28 | -------------------------------------------------------------------------------- /proxy/middlewares/metrics.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/webhookx-io/webhookx/pkg/metrics" 8 | ) 9 | 10 | type MetricsMiddleware struct { 11 | metrics *metrics.Metrics 12 | } 13 | 14 | func NewMetricsMiddleware(metrics *metrics.Metrics) *MetricsMiddleware { 15 | return &MetricsMiddleware{ 16 | metrics: metrics, 17 | } 18 | } 19 | 20 | func (m *MetricsMiddleware) Handle(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | m.metrics.ProxyRequestCounter.Add(1) 23 | start := time.Now() 24 | next.ServeHTTP(w, r) 25 | m.metrics.ProxyRequestDurationHistogram.Observe(time.Since(start).Seconds()) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/plugin/base_test.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type config struct { 12 | } 13 | 14 | func (c config) Schema() *openapi3.Schema { 15 | return &openapi3.Schema{} 16 | } 17 | 18 | type MyPlugin struct { 19 | BasePlugin[config] 20 | } 21 | 22 | func (m MyPlugin) Name() string { 23 | panic("my-plugin") 24 | } 25 | 26 | func Test(t *testing.T) { 27 | myPlugin := &MyPlugin{} 28 | assert.PanicsWithValue(t, "not implemented", func() { myPlugin.ExecuteInbound(context.TODO(), nil) }) 29 | assert.PanicsWithValue(t, "not implemented", func() { myPlugin.ExecuteOutbound(context.TODO(), nil) }) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/serializer/msgpack.go: -------------------------------------------------------------------------------- 1 | package serializer 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/vmihailenco/msgpack/v5" 7 | ) 8 | 9 | var MsgPack MsgPackSerializer 10 | 11 | type MsgPackSerializer struct{} 12 | 13 | func (s MsgPackSerializer) Serialize(val interface{}) ([]byte, error) { 14 | var buffer bytes.Buffer 15 | encoder := msgpack.NewEncoder(&buffer) 16 | encoder.SetCustomStructTag("json") 17 | err := encoder.Encode(val) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return buffer.Bytes(), nil 22 | } 23 | 24 | func (s MsgPackSerializer) Deserialize(b []byte, val interface{}) error { 25 | decoder := msgpack.NewDecoder(bytes.NewReader(b)) 26 | decoder.SetCustomStructTag("json") 27 | return decoder.Decode(val) 28 | } 29 | -------------------------------------------------------------------------------- /worker/retry/retry.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Strategy string 8 | 9 | const ( 10 | FixedStrategy Strategy = "fixed" 11 | BackoffStrategy Strategy = "backoff" 12 | ) 13 | 14 | const Stop time.Duration = -1 15 | 16 | type Retry interface { 17 | NextDelay(attempts int) time.Duration 18 | } 19 | 20 | type Option func(Retry) 21 | 22 | func NewRetry(strategy Strategy, opts ...Option) Retry { 23 | var retry Retry 24 | switch strategy { 25 | case FixedStrategy: 26 | retry = newFixedStrategyRetry() 27 | case BackoffStrategy: 28 | panic("implement me") 29 | default: 30 | panic("invalid strategy: " + strategy) 31 | } 32 | for _, opt := range opts { 33 | opt(retry) 34 | } 35 | return retry 36 | } 37 | -------------------------------------------------------------------------------- /config/modules/status.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | type StatusConfig struct { 9 | BaseConfig 10 | Listen string `yaml:"listen" json:"listen" default:"127.0.0.1:9602"` 11 | DebugEndpoints bool `yaml:"debug_endpoints" json:"debug_endpoints" default:"true" envconfig:"DEBUG_ENDPOINTS"` 12 | } 13 | 14 | func (cfg StatusConfig) Validate() error { 15 | if cfg.IsEnabled() { 16 | _, _, err := net.SplitHostPort(cfg.Listen) 17 | if err != nil { 18 | return fmt.Errorf("invalid listen '%s': %s", cfg.Listen, err) 19 | } 20 | } 21 | return nil 22 | } 23 | 24 | func (cfg StatusConfig) IsEnabled() bool { 25 | if cfg.Listen == "" || cfg.Listen == "off" { 26 | return false 27 | } 28 | return true 29 | } 30 | -------------------------------------------------------------------------------- /worker/retry/retry_test.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRetry(t *testing.T) { 11 | r1 := NewRetry(FixedStrategy) 12 | assert.NotNil(t, r1) 13 | } 14 | 15 | func TestFixedRetry(t *testing.T) { 16 | r := NewRetry(FixedStrategy) 17 | assert.Equal(t, Stop, r.NextDelay(1)) 18 | } 19 | 20 | func TestFixedRetryWithOptions(t *testing.T) { 21 | r := NewRetry(FixedStrategy, WithFixedDelay([]int64{1, 2, 3, 4})) 22 | assert.Equal(t, time.Second*1, r.NextDelay(1)) 23 | assert.Equal(t, time.Second*2, r.NextDelay(2)) 24 | assert.Equal(t, time.Second*3, r.NextDelay(3)) 25 | assert.Equal(t, time.Second*4, r.NextDelay(4)) 26 | assert.Equal(t, Stop, r.NextDelay(5)) 27 | } 28 | -------------------------------------------------------------------------------- /config/modules/admin.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | type AdminConfig struct { 4 | BaseConfig 5 | Listen string `yaml:"listen" json:"listen" default:"127.0.0.1:9601"` 6 | DebugEndpoints bool `yaml:"debug_endpoints" json:"debug_endpoints" envconfig:"DEBUG_ENDPOINTS"` 7 | TLS TLS `yaml:"tls" json:"tls"` 8 | } 9 | 10 | func (cfg AdminConfig) Validate() error { 11 | return nil 12 | } 13 | 14 | func (cfg AdminConfig) IsEnabled() bool { 15 | if cfg.Listen == "" || cfg.Listen == "off" { 16 | return false 17 | } 18 | return true 19 | } 20 | 21 | type TLS struct { 22 | Cert string `yaml:"cert" json:"cert"` 23 | Key string `yaml:"key" json:"key"` 24 | } 25 | 26 | func (cfg TLS) Enabled() bool { 27 | return cfg.Cert != "" && cfg.Key != "" 28 | } 29 | -------------------------------------------------------------------------------- /plugins/wasm/function_log.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/tetratelabs/wazero/api" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func Log(ctx context.Context, m api.Module, logLevel, strValue, strSize uint32) Status { 12 | str, ok := readString(m.Memory(), strValue, strSize) 13 | if !ok { 14 | return StatusInvalidMemoryAccess 15 | } 16 | 17 | log := zap.S() 18 | message := fmt.Sprintf("[wasm]: %s", str) 19 | 20 | switch LogLevel(logLevel) { 21 | case LogLveDebug: 22 | log.Debug(message) 23 | case LogLveInfo: 24 | log.Info(message) 25 | case LogLveWarn: 26 | log.Warn(message) 27 | case LogLveError: 28 | log.Error(message) 29 | default: 30 | return StatusBadArgument 31 | } 32 | 33 | return StatusOk 34 | } 35 | -------------------------------------------------------------------------------- /db/entities/event.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/webhookx-io/webhookx/pkg/types" 7 | "github.com/webhookx-io/webhookx/utils" 8 | ) 9 | 10 | type Event struct { 11 | ID string `json:"id" validate:"required"` 12 | EventType string `json:"event_type" db:"event_type" validate:"required"` 13 | Data json.RawMessage `json:"data" validate:"required"` 14 | IngestedAt types.Time `json:"ingested_at" db:"ingested_at"` 15 | UniqueId *string `json:"unique_id" db:"unique_id" validate:"omitempty,max=50"` 16 | 17 | BaseModel 18 | } 19 | 20 | func (m *Event) SchemaName() string { 21 | return "Event" 22 | } 23 | 24 | func (m *Event) Validate() error { 25 | return utils.Validate(m) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/errs/error.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import "errors" 4 | 5 | var ErrRequestValidation = errors.New("request validation") 6 | 7 | type Error struct { 8 | err error 9 | } 10 | 11 | func NewError(err error) *Error { 12 | return &Error{ 13 | err: err, 14 | } 15 | } 16 | 17 | func (e *Error) Error() string { 18 | return e.err.Error() 19 | } 20 | 21 | type ValidateError struct { 22 | err error 23 | Message string `json:"message"` 24 | Fields map[string]interface{} `json:"fields"` 25 | } 26 | 27 | func NewValidateError(err error) *ValidateError { 28 | return &ValidateError{ 29 | err: err, 30 | Message: err.Error(), 31 | Fields: make(map[string]interface{}), 32 | } 33 | } 34 | 35 | func (e *ValidateError) Error() string { 36 | return e.err.Error() 37 | } 38 | -------------------------------------------------------------------------------- /pkg/plugin/registry.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | type Type string 9 | 10 | const ( 11 | TypeInbound Type = "inbound" 12 | TypeOutbound Type = "outbound" 13 | ) 14 | 15 | type Registration struct { 16 | Type Type 17 | Factory func() Plugin 18 | } 19 | 20 | var mux sync.RWMutex 21 | var registry = map[string]*Registration{} 22 | 23 | func RegisterPlugin(typ Type, name string, fn func() Plugin) { 24 | mux.Lock() 25 | defer mux.Unlock() 26 | if _, ok := registry[name]; ok { 27 | panic(fmt.Sprintf("plugin '%s' already registered", name)) 28 | } 29 | 30 | registry[name] = &Registration{ 31 | Type: typ, 32 | Factory: fn, 33 | } 34 | } 35 | 36 | func GetRegistration(name string) *Registration { 37 | mux.RLock() 38 | defer mux.RUnlock() 39 | return registry[name] 40 | } 41 | -------------------------------------------------------------------------------- /pkg/secret/provider/vault/authn.go: -------------------------------------------------------------------------------- 1 | package vault 2 | 3 | type AuthN struct { 4 | Token TokenAuth `json:"token" yaml:"token"` 5 | AppRole AppRoleAuth `json:"approle" yaml:"approle"` 6 | Kubernetes KubernetesAuth `json:"kubernetes" yaml:"kubernetes"` 7 | } 8 | 9 | type TokenAuth struct { 10 | Token string `json:"token" yaml:"token"` 11 | } 12 | 13 | type AppRoleAuth struct { 14 | RoleID string `json:"role_id" yaml:"role_id" split_words:"true"` 15 | SecretID string `json:"secret_id" yaml:"secret_id" split_words:"true"` 16 | ResponseWrapping bool `json:"response_wrapping" yaml:"response_wrapping" split_words:"true"` 17 | } 18 | 19 | type KubernetesAuth struct { 20 | Role string `json:"role" yaml:"role"` 21 | TokenPath string `json:"token_path" yaml:"token_path" split_words:"true"` 22 | } 23 | -------------------------------------------------------------------------------- /pkg/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "sync" 4 | 5 | var mux sync.RWMutex 6 | var store = map[string]any{} 7 | 8 | // Get looks up a key's value 9 | func Get(key string) (any, bool) { 10 | mux.RLock() 11 | defer mux.RUnlock() 12 | if v, ok := store[key]; ok { 13 | return v, true 14 | } 15 | return nil, false 16 | } 17 | 18 | // GetDefault looks up a key's value, returns def if not exist 19 | func GetDefault(key string, def any) any { 20 | v, ok := Get(key) 21 | if !ok { 22 | return def 23 | } 24 | return v 25 | } 26 | 27 | // Set sets the key-value entry 28 | func Set(key string, value interface{}) { 29 | mux.Lock() 30 | defer mux.Unlock() 31 | store[key] = value 32 | } 33 | 34 | // Remove removes the key's entry 35 | func Remove(key string) { 36 | mux.Lock() 37 | defer mux.Unlock() 38 | delete(store, key) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/accesslog/logger.go: -------------------------------------------------------------------------------- 1 | package accesslog 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | ) 8 | 9 | type AccessLogger interface { 10 | Log(entry *Entry) 11 | } 12 | 13 | type Options struct { 14 | File string 15 | Format string 16 | Colored bool 17 | } 18 | 19 | func NewAccessLogger(name string, opts Options) (AccessLogger, error) { 20 | var writer io.Writer = os.Stdout 21 | if opts.File != "" { 22 | file, err := os.OpenFile(opts.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664) 23 | if err != nil { 24 | return nil, err 25 | } 26 | writer = file 27 | } 28 | 29 | switch opts.Format { 30 | case "text": 31 | return NewTextLogger(name, writer, opts.Colored), nil 32 | case "json": 33 | return NewJsonLogger(name, writer), nil 34 | default: 35 | return nil, errors.New("invalid format: " + opts.Format) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICHDCCAaKgAwIBAgIUVav7Dm5HbB6LQ1TlXJK7EW0EZHEwCgYIKoZIzj0EAwIw 3 | RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu 4 | dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAyMTMwNzUwMDJaFw0zNTAyMTEw 5 | NzUwMDJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYD 6 | VQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwdjAQBgcqhkjOPQIBBgUrgQQA 7 | IgNiAAQJgiQJ4WkX5lyHrB8sbI41KfLKvXqz5a6/gBhTCIQrVmQcLCyJjraQkcM3 8 | CQP5VNOtUbcGnAf7KDp+Jhh9+VXjg6y1aCVP9erVlIsxzNX4p7ID5sMJd3BldGAU 9 | i3rX3OCjUzBRMB0GA1UdDgQWBBTqVDaiV8iBJjovt5Qta7gI8nUfZzAfBgNVHSME 10 | GDAWgBTqVDaiV8iBJjovt5Qta7gI8nUfZzAPBgNVHRMBAf8EBTADAQH/MAoGCCqG 11 | SM49BAMCA2gAMGUCMQD3LcOUVatlkKH/2+2F0kHH0iOVpIkK03SdxKgMCYtW9y3H 12 | W96gaGyaHeQOV23c2JECMCphulgXCq/Q6cvdlttNj6q5Gk8n4b8TeiP4atXrhTv8 13 | MBbHdsynIDItkdMkOKCzYA== 14 | -----END CERTIFICATE----- 15 | -------------------------------------------------------------------------------- /pkg/license/loader.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/webhookx-io/webhookx/api/license" 9 | ) 10 | 11 | var ( 12 | ErrInvalidLicense = errors.New("license is invalid") 13 | ) 14 | 15 | func init() { 16 | license.PublicKey = "1c085d754c3343b8e9ad280ec5470111e47a422066071c1723b609a917e5b772" 17 | } 18 | 19 | // Load loads license 20 | func Load() (*license.License, error) { 21 | licenseJSON := os.Getenv("WEBHOOKX_LICENSE") 22 | if licenseJSON != "" { 23 | lic, err := license.ParseLicense(licenseJSON) 24 | if err != nil { 25 | return nil, fmt.Errorf("%w: %s", ErrInvalidLicense, err) 26 | } 27 | err = lic.Validate() 28 | if err != nil { 29 | return nil, fmt.Errorf("%w: %s", ErrInvalidLicense, err) 30 | } 31 | return lic, nil 32 | } 33 | 34 | return license.NewFree(), nil 35 | } 36 | -------------------------------------------------------------------------------- /test/otel-collector-config.yml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | endpoint: 0.0.0.0:4317 6 | http: 7 | endpoint: 0.0.0.0:4318 8 | 9 | processors: 10 | batch: 11 | timeout: 200ms 12 | send_batch_size: 1 13 | 14 | exporters: 15 | debug: 16 | verbosity: detailed 17 | sampling_initial: 5 18 | sampling_thereafter: 200 19 | file/metrics: 20 | path: /tmp/otel/metrics.json 21 | file/traces: 22 | path: /tmp/otel/traces.json 23 | 24 | extensions: 25 | health_check: 26 | pprof: 27 | zpages: 28 | endpoint: "0.0.0.0:55679" 29 | 30 | service: 31 | extensions: [pprof, zpages, health_check] 32 | pipelines: 33 | metrics: 34 | receivers: [otlp] 35 | exporters: [debug, file/metrics] 36 | traces: 37 | receivers: [otlp] 38 | exporters: [debug, file/traces] 39 | -------------------------------------------------------------------------------- /pkg/types/time_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var testTime, _ = time.Parse(time.RFC3339Nano, "2006-01-02T15:04:05.999Z") 12 | 13 | func TestTime(t *testing.T) { 14 | t1 := NewTime(testTime) 15 | s, err := json.Marshal(t1) 16 | assert.Nil(t, err) 17 | assert.EqualValues(t, "1136214245999", string(s)) 18 | 19 | var t2 Time 20 | assert.NoError(t, json.Unmarshal([]byte("1136214245999"), &t2)) 21 | assert.True(t, t1.Equal(t2)) 22 | } 23 | 24 | func TestZeroTime(t *testing.T) { 25 | var zeroTime1 Time 26 | s, err := json.Marshal(zeroTime1) 27 | assert.Nil(t, err) 28 | assert.EqualValues(t, "0", string(s)) 29 | 30 | var zeroTime2 Time 31 | assert.NoError(t, json.Unmarshal([]byte("0"), &zeroTime2)) 32 | assert.True(t, zeroTime1.Equal(zeroTime2)) 33 | } 34 | -------------------------------------------------------------------------------- /test/helper/license.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | api_license "github.com/webhookx-io/webhookx/api/license" 5 | "github.com/webhookx-io/webhookx/pkg/license" 6 | ) 7 | 8 | type mockLicenser struct{} 9 | 10 | var _ license.Licenser = mockLicenser{} 11 | 12 | func (l mockLicenser) Allow(feature string) bool { return true } 13 | func (l mockLicenser) License() *api_license.License { 14 | license := api_license.NewFree() 15 | license.Plan = "enterprise" 16 | return license 17 | } 18 | func (l mockLicenser) AllowAPI(workspace string, path string, method string) bool { return true } 19 | 20 | func MockLicenser(licenser license.Licenser) func() { 21 | if licenser == nil { 22 | licenser = mockLicenser{} 23 | } 24 | def := license.GetLicenser() 25 | reset := func() { 26 | license.SetLicenser(def) 27 | } 28 | license.SetLicenser(licenser) 29 | return reset 30 | } 31 | -------------------------------------------------------------------------------- /db/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | type Queryer interface { 4 | Offset() int64 5 | Limit() int64 6 | WhereMap() map[string]interface{} 7 | Orders() []*Order 8 | } 9 | 10 | type Query struct { 11 | offset int64 12 | limit int64 13 | orders []*Order 14 | } 15 | 16 | func (q *Query) Page(pageNo, pageSize uint64) { 17 | if pageNo < 1 { 18 | pageNo = 1 19 | } 20 | offset := (pageNo - 1) * pageSize 21 | q.offset = int64(offset) 22 | q.limit = int64(int(pageSize)) 23 | } 24 | 25 | func (q *Query) Offset() int64 { 26 | return q.offset 27 | } 28 | 29 | func (q *Query) Limit() int64 { 30 | return q.limit 31 | } 32 | 33 | func (q *Query) WhereMap() map[string]interface{} { 34 | return nil 35 | } 36 | 37 | func (q *Query) Orders() []*Order { 38 | return q.orders 39 | } 40 | 41 | func (q *Query) Order(column string, sort Sort) { 42 | q.orders = append(q.orders, &Order{column, sort}) 43 | } 44 | -------------------------------------------------------------------------------- /test/secret-reference/testdata/aws-secrets.yml: -------------------------------------------------------------------------------- 1 | log: 2 | level: warn 3 | 4 | secret: 5 | aws: 6 | region: us-east-1 7 | url: http://localhost:4566 8 | 9 | database: 10 | host: "{secret://aws/webhookx/config.key_boolean}" 11 | port: "{secret://aws/webhookx/config.key_integer}" 12 | username: "{secret://aws/webhookx/config.key_string}" 13 | password: "{secret://aws/webhookx/config.key_integer}" 14 | database: "{secret://aws/webhookx/config.key_float}" 15 | parameters: "{secret://aws/webhookx/config.key_array.2}" 16 | 17 | redis: 18 | host: "{secret://aws/webhookx/config.key_nested.key_boolean}" 19 | port: "{secret://aws/webhookx/config.key_nested.key_integer}" 20 | password: "{secret://aws/webhookx/config.key_nested.key_string}" 21 | 22 | worker: 23 | enabled: "{secret://aws/webhookx/config.key_nested.key_boolean}" 24 | 25 | tracing: 26 | sampling_rate: "{secret://aws/webhookx/config.key_float}" 27 | -------------------------------------------------------------------------------- /test/init-vault.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "setting approle auth" 4 | vault auth enable approle 5 | vault policy write webhookx-read - < 0 32 | res.Remaining = r.Remaining 33 | res.Reset = r.ResetAfter 34 | if r.RetryAfter != -1 { 35 | res.RetryAfter = r.RetryAfter 36 | } 37 | return res, nil 38 | } 39 | -------------------------------------------------------------------------------- /test/secret-reference/testdata/vault-secrets.yml: -------------------------------------------------------------------------------- 1 | log: 2 | level: warn 3 | 4 | secret: 5 | vault: 6 | authn: 7 | token: 8 | token: root 9 | 10 | database: 11 | host: "{secret://vault/webhookx/config.key_boolean}" 12 | port: "{secret://vault/webhookx/config.key_integer}" 13 | username: "{secret://vault/webhookx/config.key_string}" 14 | password: "{secret://vault/webhookx/config.key_integer}" 15 | database: "{secret://vault/webhookx/config.key_float}" 16 | parameters: "{secret://vault/webhookx/config.key_array.2}" 17 | 18 | redis: 19 | host: "{secret://vault/webhookx/config.key_nested.key_boolean}" 20 | port: "{secret://vault/webhookx/config.key_nested.key_integer}" 21 | password: "{secret://vault/webhookx/config.key_nested.key_string}" 22 | 23 | worker: 24 | enabled: "{secret://vault/webhookx/config.key_nested.key_boolean}" 25 | 26 | tracing: 27 | sampling_rate: "{secret://vault/webhookx/config.key_float}" 28 | -------------------------------------------------------------------------------- /db/entities/schema.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getkin/kin-openapi/openapi3" 7 | ) 8 | 9 | type Schema interface { 10 | SchemaName() string 11 | } 12 | 13 | var spec *openapi3.T 14 | 15 | func LookupSchema(name string) *openapi3.Schema { 16 | s, err := spec.Components.Schemas.JSONLookup(name) 17 | if err != nil { 18 | panic(fmt.Errorf("failed to lookup JSON schema %q: %w", name, err)) 19 | } 20 | return s.(*openapi3.Schema) 21 | } 22 | 23 | func LoadOpenAPI(bytes []byte) { 24 | loader := openapi3.NewLoader() 25 | doc, err := loader.LoadFromData(bytes) 26 | if err != nil { 27 | panic(fmt.Errorf("failed to load OpenAPI document: %w", err)) 28 | } 29 | 30 | if err = doc.Validate(loader.Context, 31 | openapi3.EnableSchemaFormatValidation(), 32 | openapi3.DisableSchemaDefaultsValidation(), 33 | ); err != nil { 34 | panic(fmt.Errorf("OpenAPI document validation failed: %w", err)) 35 | } 36 | 37 | spec = doc 38 | } 39 | -------------------------------------------------------------------------------- /plugins/webhookx_signature/plugin_test.go: -------------------------------------------------------------------------------- 1 | package webhookx_signature 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/webhookx-io/webhookx/pkg/plugin" 10 | ) 11 | 12 | func TestExecute(t *testing.T) { 13 | p := new(SignaturePlugin) 14 | p.ts = time.Unix(1726285679, 0) 15 | p.Config.SigningSecret = "QGvaZ0uPwA9nYi7jr31JtZn1EKK4pJpK" 16 | 17 | pluginReq := &plugin.Outbound{ 18 | URL: "https://example.com", 19 | Method: "POST", 20 | Headers: make(map[string]string), 21 | Payload: "foo", 22 | } 23 | p.ExecuteOutbound(context.TODO(), pluginReq) 24 | 25 | assert.Equal(t, "https://example.com", pluginReq.URL) 26 | assert.Equal(t, "POST", pluginReq.Method) 27 | assert.Equal(t, "foo", pluginReq.Payload) 28 | assert.Equal(t, "v1=e2af2618d5ffd700eb369904b7237ec4ac7d37873cfe6654265af2e53b44da6b", pluginReq.Headers["webhookx-signature"]) 29 | assert.Equal(t, "1726285679", pluginReq.Headers["webhookx-timestamp"]) 30 | } 31 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - windows 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | ldflags: 18 | - -X github.com/webhookx-io/webhookx/config.COMMIT={{ .ShortCommit }} 19 | - -X github.com/webhookx-io/webhookx/config.VERSION={{ .Tag }} 20 | main: ./cmd/main 21 | 22 | checksum: 23 | name_template: 'checksums.txt' 24 | 25 | brews: 26 | - repository: 27 | owner: webhookx-io 28 | name: homebrew-webhookx 29 | token: "{{ .Env.TAP_GITHUB_TOKEN }}" 30 | directory: Formula 31 | commit_author: 32 | name: "WebhookX" 33 | email: "webhookx@gmail.com" 34 | homepage: "https://github.com/webhookx-io/webhookx" 35 | description: "an open-source webhooks gateway for message receiving, processing, and delivering." 36 | test: | 37 | system "#{bin}/webhookx", "version" 38 | -------------------------------------------------------------------------------- /pkg/taskqueue/queue.go: -------------------------------------------------------------------------------- 1 | package taskqueue 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | ) 8 | 9 | type TaskMessage struct { 10 | ID string 11 | ScheduledAt time.Time 12 | Data interface{} 13 | data []byte 14 | } 15 | 16 | func (t *TaskMessage) String() string { 17 | return t.ID + ":" + string(t.data) 18 | } 19 | 20 | func (t *TaskMessage) UnmarshalData(v interface{}) error { 21 | return json.Unmarshal(t.data, v) 22 | } 23 | 24 | func (t *TaskMessage) MarshalData() ([]byte, error) { 25 | return json.Marshal(t.Data) 26 | } 27 | 28 | type GetOptions struct { 29 | Count int64 30 | } 31 | 32 | type TaskQueue interface { 33 | Add(ctx context.Context, tasks []*TaskMessage) error 34 | Get(ctx context.Context, opts *GetOptions) (tasks []*TaskMessage, err error) 35 | Delete(ctx context.Context, task *TaskMessage) error 36 | Size(ctx context.Context) (int64, error) 37 | Schedule(ctx context.Context, task *TaskMessage) error 38 | Stats() map[string]interface{} 39 | } 40 | -------------------------------------------------------------------------------- /test/fixtures/plugins/hello/hello_plugin.go: -------------------------------------------------------------------------------- 1 | package hello 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/webhookx-io/webhookx/pkg/plugin" 9 | ) 10 | 11 | var schemaJSON = ` 12 | { 13 | "type": "object", 14 | "properties": { 15 | "message": { 16 | "type": "string" 17 | } 18 | }, 19 | "required": ["message"] 20 | } 21 | ` 22 | 23 | type Config struct { 24 | Message string `json:"message"` 25 | } 26 | 27 | func (c Config) Schema() *openapi3.Schema { 28 | schema := openapi3.NewSchema() 29 | err := schema.UnmarshalJSON([]byte(schemaJSON)) 30 | if err != nil { 31 | panic(err) 32 | } 33 | return schema 34 | } 35 | 36 | type HelloPlugin struct { 37 | plugin.BasePlugin[Config] 38 | } 39 | 40 | func (p *HelloPlugin) Name() string { 41 | return "hello" 42 | } 43 | 44 | func (p *HelloPlugin) ExecuteOutbound(ctx context.Context, outbound *plugin.Outbound) error { 45 | fmt.Println(p.Config.Message) 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /worker/deliverer/deliverer.go: -------------------------------------------------------------------------------- 1 | package deliverer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type Deliverer interface { 11 | Deliver(ctx context.Context, req *Request) (res *Response) 12 | } 13 | 14 | type Request struct { 15 | Request *http.Request 16 | URL string 17 | Method string 18 | Payload []byte 19 | Headers map[string]string 20 | Timeout time.Duration 21 | } 22 | 23 | type AclDecision struct { 24 | Denied bool 25 | } 26 | 27 | type Response struct { 28 | Request *Request 29 | ACL AclDecision 30 | StatusCode int 31 | Header http.Header 32 | ResponseBody []byte 33 | Error error 34 | Latancy time.Duration 35 | ProxyStatusCode int 36 | } 37 | 38 | func (r *Response) Is2xx() bool { 39 | return r.StatusCode >= 200 && r.StatusCode <= 299 40 | } 41 | 42 | func (r *Response) String() string { 43 | return fmt.Sprintf("%s %s %d %dms", r.Request.Method, r.Request.URL, r.StatusCode, r.Latancy.Milliseconds()) 44 | } 45 | -------------------------------------------------------------------------------- /plugins/jsonschema_validator/jsonschema/jsonschema.go: -------------------------------------------------------------------------------- 1 | package jsonschema 2 | 3 | import ( 4 | "github.com/getkin/kin-openapi/openapi3" 5 | lru "github.com/hashicorp/golang-lru/v2" 6 | "github.com/webhookx-io/webhookx/pkg/openapi" 7 | "github.com/webhookx-io/webhookx/utils" 8 | ) 9 | 10 | type JSONSchema struct { 11 | schemaDef string 12 | hex string 13 | } 14 | 15 | func New(schemaDef []byte) *JSONSchema { 16 | return &JSONSchema{ 17 | schemaDef: string(schemaDef), 18 | hex: utils.Sha256(string(schemaDef)), 19 | } 20 | } 21 | 22 | var cache, _ = lru.New[string, *openapi3.Schema](128) 23 | 24 | func (s *JSONSchema) Validate(ctx *ValidatorContext) error { 25 | schema, ok := cache.Get(s.hex) 26 | if !ok { 27 | schema = &openapi3.Schema{} 28 | err := schema.UnmarshalJSON([]byte(s.schemaDef)) 29 | if err != nil { 30 | return err 31 | } 32 | cache.Add(s.hex, schema) 33 | } 34 | 35 | err := openapi.Validate(schema, ctx.HTTPRequest.Data) 36 | if err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /test/metrics/types.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "go.opentelemetry.io/otel/sdk/instrumentation" 5 | "go.opentelemetry.io/otel/sdk/metric/metricdata" 6 | ) 7 | 8 | type ResourceMetrics struct { 9 | Resource Resource `json:"resource,omitempty"` 10 | ScopeMetrics []ScopeMetrics `json:"scopeMetrics"` 11 | } 12 | 13 | type Resource struct { 14 | Attributes []KeyValue `json:"attributes"` 15 | } 16 | 17 | type KeyValue struct { 18 | Key string `json:"key"` 19 | Value interface{} `json:"value"` 20 | } 21 | 22 | type ScopeMetrics struct { 23 | Scope instrumentation.Scope `json:"scope"` 24 | Metrics []Metrics `json:"metrics"` 25 | } 26 | 27 | type Metrics struct { 28 | Name string `json:"name"` 29 | Description string `json:"description"` 30 | Unit string `json:"unit"` 31 | Data metricdata.Aggregation `json:"data"` 32 | } 33 | 34 | type ExportRequest struct { 35 | ResourceMetrics []ResourceMetrics `json:"resourceMetrics"` 36 | } 37 | -------------------------------------------------------------------------------- /db/dao/utils.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/jackc/pgx/v5/pgconn" 9 | "github.com/webhookx-io/webhookx/utils" 10 | ) 11 | 12 | // EachField traverse each database field 13 | func EachField(entity interface{}, fn func(field reflect.StructField, value reflect.Value, column string)) { 14 | t := reflect.TypeOf(entity) 15 | if t.Kind() == reflect.Ptr { 16 | t = t.Elem() 17 | } 18 | v := reflect.ValueOf(entity) 19 | if v.Kind() == reflect.Ptr { 20 | v = v.Elem() 21 | } 22 | for i := 0; i < t.NumField(); i++ { 23 | field := t.Field(i) 24 | value := v.Field(i) 25 | column := utils.DefaultIfZero(field.Tag.Get("db"), strings.ToLower(field.Name)) 26 | if column == "-" { 27 | continue 28 | } 29 | if field.Anonymous { 30 | EachField(value.Interface(), fn) 31 | } else { 32 | fn(field, value, column) 33 | } 34 | } 35 | } 36 | 37 | func is23505(err error) bool { 38 | var pgErr *pgconn.PgError 39 | return errors.As(err, &pgErr) && pgErr.Code == "23505" 40 | } 41 | -------------------------------------------------------------------------------- /db/dao/workspace_dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/webhookx-io/webhookx/constants" 8 | "github.com/webhookx-io/webhookx/db/entities" 9 | "github.com/webhookx-io/webhookx/eventbus" 10 | ) 11 | 12 | type workspaceDAO struct { 13 | *DAO[entities.Workspace] 14 | } 15 | 16 | func NewWorkspaceDAO(db *sqlx.DB, bus *eventbus.EventBus) WorkspaceDAO { 17 | opts := Options{ 18 | Table: "workspaces", 19 | EntityName: "workspace", 20 | Workspace: false, 21 | CachePropagate: true, 22 | CacheName: constants.WorkspaceCacheKey.Name, 23 | } 24 | return &workspaceDAO{ 25 | DAO: NewDAO[entities.Workspace](db, bus, opts), 26 | } 27 | } 28 | 29 | func (dao *workspaceDAO) GetDefault(ctx context.Context) (*entities.Workspace, error) { 30 | return dao.selectByField(ctx, "name", "default") 31 | } 32 | 33 | func (dao *workspaceDAO) GetWorkspace(ctx context.Context, name string) (*entities.Workspace, error) { 34 | return dao.selectByField(ctx, "name", name) 35 | } 36 | -------------------------------------------------------------------------------- /examples/wasm/customize-headers/README.md: -------------------------------------------------------------------------------- 1 | # Customize headers examples 2 | 3 | The examples in this directory show you how to customize request headers in different languages. 4 | 5 | - [AssemblyCcript](assemblyscript) 6 | - [Rust](rust) 7 | - [Go](go) 8 | - [TinyGo](tinygo) 9 | 10 | 11 | ``` 12 | $ webhookx admin sync webhookx.yml 13 | ``` 14 | 15 | ```yaml 16 | # webhookx.yml 17 | endpoints: 18 | - name: default-endpoint 19 | request: 20 | timeout: 10000 21 | url: https://httpbin.org/anything 22 | method: POST 23 | retry: 24 | strategy: fixed 25 | config: 26 | attempts: [0, 3600, 3600] 27 | events: [ "charge.succeeded" ] 28 | plugins: 29 | - name: wasm 30 | config: 31 | file: /path/to/your.wasm 32 | envs: 33 | secret: secret-value 34 | sources: 35 | - name: default-source 36 | path: / 37 | methods: [ "POST" ] 38 | response: 39 | code: 200 40 | content_type: application/json 41 | body: '{"message": "OK"}' 42 | ``` 43 | -------------------------------------------------------------------------------- /utils/conv_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPointer(t *testing.T) { 10 | s := "string" 11 | assert.Equal(t, &s, Pointer(s)) 12 | 13 | b := true 14 | assert.Equal(t, &b, Pointer(b)) 15 | 16 | f := 1.1 17 | assert.Equal(t, &f, Pointer(f)) 18 | 19 | i := 1 20 | assert.Equal(t, &i, Pointer(i)) 21 | } 22 | 23 | func TestPointerValue(t *testing.T) { 24 | s := "string" 25 | assert.Equal(t, s, PointerValue(Pointer(s))) 26 | 27 | b := true 28 | assert.Equal(t, b, PointerValue(Pointer(b))) 29 | 30 | f := 1.1 31 | assert.Equal(t, f, PointerValue(Pointer(f))) 32 | 33 | i := 1 34 | assert.Equal(t, i, PointerValue(Pointer(i))) 35 | } 36 | 37 | func TestPointerValueNil(t *testing.T) { 38 | var s *string 39 | assert.Equal(t, "", PointerValue(s)) 40 | 41 | var b *bool 42 | assert.Equal(t, false, PointerValue(b)) 43 | 44 | var f *float64 45 | assert.Equal(t, float64(0), PointerValue(f)) 46 | 47 | var i *int 48 | assert.Equal(t, 0, PointerValue(i)) 49 | } 50 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | ) 7 | 8 | func DefaultIfZero[T any](v T, fallback T) T { 9 | if reflect.ValueOf(v).IsZero() { 10 | return fallback 11 | } 12 | return v 13 | } 14 | 15 | func MergeMap(dst, src map[string]interface{}) { 16 | for k, v := range src { 17 | if srcv, ok := v.(map[string]interface{}); ok { 18 | if dstv, ok := dst[k].(map[string]interface{}); ok { 19 | MergeMap(dstv, srcv) 20 | } else { 21 | dst[k] = srcv 22 | } 23 | } else { 24 | dst[k] = v 25 | } 26 | } 27 | } 28 | 29 | func StructToMap(v interface{}) (map[string]interface{}, error) { 30 | b, err := json.Marshal(v) 31 | if err != nil { 32 | return nil, err 33 | } 34 | data := make(map[string]interface{}) 35 | err = json.Unmarshal(b, &data) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return data, nil 40 | } 41 | 42 | func MapToStruct(data map[string]interface{}, v interface{}) error { 43 | b, err := json.Marshal(data) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | return json.Unmarshal(b, v) 49 | } 50 | -------------------------------------------------------------------------------- /constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/webhookx-io/webhookx/config" 7 | ) 8 | 9 | // Task Queue 10 | const ( 11 | TaskQueueName = "webhookx:queue" 12 | TaskQueueDataName = "webhookx:queue_data" 13 | TaskQueueVisibilityTimeout = time.Second * 65 14 | TaskQueuePreScheduleTimeWindow = time.Minute * 3 15 | ) 16 | 17 | // Redis Queue 18 | const ( 19 | QueueRedisQueueName = "webhookx:proxy_queue" 20 | QueueRedisGroupName = "group_default" 21 | QueueRedisConsumerName = "consumer_default" 22 | QueueRedisVisibilityTimeout = time.Second * 60 23 | ) 24 | 25 | type Header struct { 26 | Name string 27 | Value string 28 | } 29 | 30 | var ( 31 | HeaderEventId = "X-Webhookx-Event-Id" 32 | DefaultResponseHeaders = []Header{ 33 | {Name: "Server", Value: "WebhookX/" + config.VERSION}, 34 | } 35 | DefaultDelivererRequestHeaders = []Header{ 36 | {Name: "User-Agent", Value: "WebhookX/" + config.VERSION}, 37 | {Name: "Content-Type", Value: "application/json; charset=utf-8"}, 38 | } 39 | ) 40 | -------------------------------------------------------------------------------- /eventbus/types.go: -------------------------------------------------------------------------------- 1 | package eventbus 2 | 3 | import "encoding/json" 4 | 5 | const ( 6 | EventCRUD = "crud" 7 | EventEventFanout = "event.fanout" 8 | ) 9 | 10 | type Bus interface { 11 | ClusteringBroadcast(event string, data interface{}) error 12 | ClusteringSubscribe(channel string, fn func(data []byte)) 13 | Broadcast(channel string, data interface{}) 14 | Subscribe(channel string, cb Callback) 15 | } 16 | 17 | // Message clustering message 18 | type Message struct { 19 | Event string `json:"event"` 20 | Time int64 `json:"time"` 21 | Node string `json:"node"` 22 | Data json.RawMessage `json:"data"` 23 | } 24 | 25 | type Callback func(data interface{}) 26 | 27 | type CrudData struct { 28 | Entity string `json:"entity"` 29 | ID string `json:"id"` 30 | WID string `json:"wid"` 31 | CacheName string `json:"cache_name"` 32 | Data json.RawMessage `json:"data"` 33 | } 34 | 35 | type EventFanoutData struct { 36 | EventId string `json:"event_id"` 37 | AttemptIds []string `json:"attempt_ids"` 38 | } 39 | -------------------------------------------------------------------------------- /config/modules/log.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | ) 7 | 8 | type LogLevel string 9 | 10 | const ( 11 | LogLevelDebug LogLevel = "debug" 12 | LogLevelInfo LogLevel = "info" 13 | LogLevelWarn LogLevel = "warn" 14 | LogLevelError LogLevel = "error" 15 | ) 16 | 17 | type LogFormat string 18 | 19 | const ( 20 | LogFormatText LogFormat = "text" 21 | LogFormatJson LogFormat = "json" 22 | ) 23 | 24 | type LogConfig struct { 25 | BaseConfig 26 | Level LogLevel `yaml:"level" json:"level" default:"info"` 27 | Format LogFormat `yaml:"format" json:"format" default:"text"` 28 | Colored bool `yaml:"colored" json:"colored" default:"true"` 29 | File string `yaml:"file" json:"file"` 30 | } 31 | 32 | func (cfg LogConfig) Validate() error { 33 | if !slices.Contains([]LogLevel{LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError}, cfg.Level) { 34 | return fmt.Errorf("invalid level: %s", cfg.Level) 35 | } 36 | if !slices.Contains([]LogFormat{LogFormatText, LogFormatJson}, cfg.Format) { 37 | return fmt.Errorf("invalid format: %s", cfg.Format) 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/http/middlewares/middlewares.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "runtime" 8 | 9 | "github.com/webhookx-io/webhookx/db/errs" 10 | "github.com/webhookx-io/webhookx/pkg/http/response" 11 | "github.com/webhookx-io/webhookx/pkg/types" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func PanicRecovery(h http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | defer func() { 18 | if e := recover(); e != nil { 19 | var err error 20 | switch v := e.(type) { 21 | case error: 22 | err = v 23 | default: 24 | err = errors.New(fmt.Sprint(e)) 25 | } 26 | 27 | if e, ok := err.(*errs.DBError); ok { 28 | response.JSON(w, 400, types.ErrorResponse{Message: e.Error()}) 29 | return 30 | } 31 | 32 | buf := make([]byte, 2048) 33 | n := runtime.Stack(buf, false) 34 | buf = buf[:n] 35 | 36 | zap.S().Errorf("panic recovered: %v\n %s", err, buf) 37 | response.JSON(w, 500, types.ErrorResponse{Message: "internal error"}) 38 | } 39 | }() 40 | 41 | h.ServeHTTP(w, r) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /proxy/middlewares/recovery.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "runtime" 8 | 9 | "github.com/webhookx-io/webhookx/db/dao" 10 | "github.com/webhookx-io/webhookx/pkg/http/response" 11 | "github.com/webhookx-io/webhookx/pkg/types" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func PanicRecovery(h http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | defer func() { 18 | if e := recover(); e != nil { 19 | var err error 20 | switch v := e.(type) { 21 | case error: 22 | err = v 23 | default: 24 | err = errors.New(fmt.Sprint(e)) 25 | } 26 | 27 | if errors.Is(err, dao.ErrConstraintViolation) { 28 | response.JSON(w, 400, types.ErrorResponse{Message: err.Error()}) 29 | return 30 | } 31 | 32 | buf := make([]byte, 2048) 33 | n := runtime.Stack(buf, false) 34 | buf = buf[:n] 35 | 36 | zap.S().Errorf("panic recovered: %v\n %s", err, buf) 37 | 38 | response.JSON(w, 500, types.ErrorResponse{Message: "internal error"}) 39 | } 40 | }() 41 | 42 | h.ServeHTTP(w, r) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /plugins/basic-auth/plugin.go: -------------------------------------------------------------------------------- 1 | package basic_auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/webhookx-io/webhookx/db/entities" 8 | "github.com/webhookx-io/webhookx/pkg/http/response" 9 | "github.com/webhookx-io/webhookx/pkg/plugin" 10 | ) 11 | 12 | type Config struct { 13 | Username string `json:"username"` 14 | Password string `json:"password"` 15 | } 16 | 17 | func (c Config) Schema() *openapi3.Schema { 18 | return entities.LookupSchema("BasicAuthPluginConfiguration") 19 | } 20 | 21 | type BasicAuthPlugin struct { 22 | plugin.BasePlugin[Config] 23 | } 24 | 25 | func (p *BasicAuthPlugin) Name() string { 26 | return "basic-auth" 27 | } 28 | 29 | func (p *BasicAuthPlugin) ExecuteInbound(ctx context.Context, inbound *plugin.Inbound) (result plugin.InboundResult, err error) { 30 | username, password, ok := inbound.Request.BasicAuth() 31 | if !ok || username != p.Config.Username || password != p.Config.Password { 32 | response.JSON(inbound.Response, 401, `{"message":"Unauthorized"}`) 33 | result.Terminated = true 34 | } 35 | 36 | result.Payload = inbound.RawBody 37 | return 38 | } 39 | -------------------------------------------------------------------------------- /test/cmd/testdata/dump.yml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - id: 2q6ItdkHcFz8jQaXxrGp35xsShS 3 | name: null 4 | description: null 5 | enabled: true 6 | request: 7 | url: http://localhost:9999/anything 8 | method: POST 9 | headers: {} 10 | timeout: 0 11 | retry: 12 | strategy: fixed 13 | config: 14 | attempts: 15 | - 0 16 | - 3 17 | - 3 18 | events: 19 | - foo.bar 20 | metadata: 21 | k: v 22 | rate_limit: null 23 | plugins: 24 | - id: 2q6ItZRVNB0EyVr6j8Pxa7VTohU 25 | name: webhookx-signature 26 | enabled: true 27 | endpoint_id: 2q6ItdkHcFz8jQaXxrGp35xsShS 28 | source_id: null 29 | config: 30 | signing_secret: test 31 | metadata: 32 | k: v 33 | sources: 34 | - id: 2q6ItgNdNEIvoJ2wffn5G5j8HYC 35 | name: null 36 | enabled: true 37 | type: http 38 | config: 39 | http: 40 | path: / 41 | methods: 42 | - POST 43 | response: null 44 | async: false 45 | metadata: 46 | k: v 47 | rate_limit: null 48 | plugins: [] 49 | -------------------------------------------------------------------------------- /pkg/stats/stats.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "maps" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | type Provider interface { 10 | Stats() map[string]interface{} 11 | } 12 | 13 | type ProviderFunc func() map[string]interface{} 14 | 15 | func (f ProviderFunc) Stats() map[string]interface{} { 16 | return f() 17 | } 18 | 19 | var ( 20 | mux sync.RWMutex 21 | providers []Provider 22 | ) 23 | 24 | func Register(p Provider) { 25 | mux.Lock() 26 | defer mux.Unlock() 27 | providers = append(providers, p) 28 | } 29 | 30 | type Stats map[string]interface{} 31 | 32 | func (m Stats) Int(key string) int { 33 | v, ok := m[key] 34 | if !ok { 35 | return 0 36 | } 37 | return v.(int) 38 | } 39 | 40 | func (m Stats) Int64(key string) int64 { 41 | v, ok := m[key] 42 | if !ok { 43 | return 0 44 | } 45 | return v.(int64) 46 | } 47 | 48 | func (m Stats) Time(key string) time.Time { 49 | v, ok := m[key] 50 | if !ok { 51 | return time.Time{} 52 | } 53 | return v.(time.Time) 54 | } 55 | 56 | func Collect() Stats { 57 | mux.RLock() 58 | defer mux.RUnlock() 59 | 60 | stats := make(map[string]interface{}) 61 | for _, p := range providers { 62 | maps.Copy(stats, p.Stats()) 63 | } 64 | return stats 65 | } 66 | -------------------------------------------------------------------------------- /pkg/accesslog/logger_text.go: -------------------------------------------------------------------------------- 1 | package accesslog 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/webhookx-io/webhookx/utils" 8 | ) 9 | 10 | type TextLogger struct { 11 | logger *zerolog.Logger 12 | } 13 | 14 | func NewTextLogger(name string, writer io.Writer, colored bool) *TextLogger { 15 | zerolog.TimeFieldFormat = "2006/01/02 15:04:05.000" 16 | zerolog.TimestampFieldName = "ts" 17 | 18 | output := zerolog.ConsoleWriter{ 19 | Out: writer, 20 | NoColor: !colored, 21 | TimeFormat: "2006/01/02 15:04:05.000", 22 | } 23 | output.PartsOrder = []string{ 24 | zerolog.TimestampFieldName, 25 | "name", 26 | zerolog.LevelFieldName, 27 | zerolog.CallerFieldName, 28 | zerolog.MessageFieldName, 29 | } 30 | output.FieldsExclude = []string{"name"} 31 | output.FormatLevel = func(i interface{}) string { return "" } 32 | output.FormatFieldName = func(i interface{}) string { return "" } 33 | name = utils.Colorize("["+name+"]", utils.ColorDarkGray, colored) 34 | logger := zerolog.New(output).With().Str("name", name).Logger() 35 | return &TextLogger{ 36 | logger: &logger, 37 | } 38 | } 39 | 40 | func (l *TextLogger) Log(entry *Entry) { 41 | l.logger.Log().Timestamp().Msg(entry.String()) 42 | } 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DIR := $(shell pwd) 2 | 3 | LDFLAGS = --ldflags "\ 4 | -X github.com/webhookx-io/webhookx/config.COMMIT=`git rev-parse --verify --short HEAD` \ 5 | -X github.com/webhookx-io/webhookx/config.VERSION=`git tag -l --points-at HEAD | head -n 1 | sed 's/^v//'`" 6 | 7 | .PHONY: clean build install generate test test-coverage test-integration \ 8 | test-integration-coverage goreleaser migrate-create test-deps 9 | 10 | clean: 11 | go clean 12 | go clean -testcache 13 | 14 | build: 15 | CGO_ENABLED=0 go build -o webhookx ${LDFLAGS} ./cmd/main 16 | 17 | install: build 18 | cp webhookx $(HOME)/go/bin/webhookx 19 | 20 | generate: 21 | go generate ./... 22 | 23 | test-deps: 24 | mkdir -p test/output/otel 25 | docker compose -f test/docker-compose.yml up -d 26 | 27 | test-unit: clean 28 | go test $$(go list ./... | grep -v /test/ | grep -v /examples/ ) $(FLAGS) 29 | cd api/license && go test $(FLAGS) 30 | 31 | test-o11: clean 32 | ginkgo -r $(FLAGS) ./test/metrics ./test/tracing 33 | 34 | test-main: clean 35 | ginkgo -r --skip-package=metrics,tracing $(FLAGS) ./test 36 | 37 | goreleaser: 38 | goreleaser release --snapshot --clean 39 | 40 | migrate-create: 41 | migrate create -ext sql -dir db/migrations -seq -digits 1 $(message) 42 | -------------------------------------------------------------------------------- /plugins/function/sdk/sdk.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/dop251/goja" 7 | "github.com/webhookx-io/webhookx/db/entities" 8 | ) 9 | 10 | type SDK struct { 11 | Request *RequestSDK `json:"request"` 12 | Response *ResponseSDK `json:"response"` 13 | Utils *UtilsSDK `json:"utils"` 14 | Log *LogSDK `json:"log"` 15 | 16 | opts *Options 17 | } 18 | 19 | type Options struct { 20 | VM *goja.Runtime 21 | Context *ExecutionContext 22 | Result *ExecutionResult 23 | } 24 | 25 | func NewSDK(opts *Options) *SDK { 26 | return &SDK{ 27 | Request: NewRequestSDK(opts), 28 | Utils: NewUtilsSDK(), 29 | Log: NewLogSDK(), 30 | Response: NewResponseSDK(opts), 31 | opts: opts, 32 | } 33 | } 34 | 35 | type HTTPRequest struct { 36 | R *http.Request 37 | Body []byte 38 | } 39 | 40 | type HTTPResponse struct { 41 | Code int 42 | Headers map[string]string 43 | Body string 44 | } 45 | 46 | type ExecutionContext struct { 47 | HTTPRequest *HTTPRequest 48 | 49 | Workspace *entities.Workspace 50 | Source *entities.Source 51 | Event *entities.Event 52 | } 53 | 54 | type ExecutionResult struct { 55 | ReturnValue interface{} 56 | HTTPResponse *HTTPResponse 57 | } 58 | -------------------------------------------------------------------------------- /test/admin/license_test.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "github.com/go-resty/resty/v2" 5 | . "github.com/onsi/ginkgo/v2" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/webhookx-io/webhookx/app" 8 | "github.com/webhookx-io/webhookx/test/helper" 9 | "github.com/webhookx-io/webhookx/utils" 10 | ) 11 | 12 | var _ = Describe("/license", Ordered, func() { 13 | 14 | var adminClient *resty.Client 15 | var app *app.Application 16 | 17 | BeforeAll(func() { 18 | adminClient = helper.AdminClient() 19 | app = utils.Must(helper.Start(map[string]string{})) 20 | }) 21 | 22 | AfterAll(func() { 23 | app.Stop() 24 | }) 25 | 26 | Context("GET", func() { 27 | It("retrieve license", func() { 28 | expected := `{ 29 | "id": "00000000-0000-0000-0000-000000000000", 30 | "plan": "free", 31 | "customer": "anonymous", 32 | "expired_at": "2099-12-31T23:59:59Z", 33 | "created_at": "1996-08-24T00:00:00Z", 34 | "version": "1", 35 | "signature": "" 36 | }` 37 | resp, err := adminClient.R(). 38 | Get("/license") 39 | assert.Nil(GinkgoT(), err) 40 | assert.Equal(GinkgoT(), 200, resp.StatusCode()) 41 | assert.JSONEq(GinkgoT(), expected, string(resp.Body())) 42 | }) 43 | }) 44 | 45 | }) 46 | -------------------------------------------------------------------------------- /config/modules/redis.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/redis/go-redis/v9" 7 | "github.com/redis/go-redis/v9/maintnotifications" 8 | "github.com/webhookx-io/webhookx/config/types" 9 | ) 10 | 11 | type RedisConfig struct { 12 | BaseConfig 13 | Host string `yaml:"host" json:"host" default:"127.0.0.1"` 14 | Port uint32 `yaml:"port" json:"port" default:"6379"` 15 | Password types.Password `yaml:"password" json:"password" default:""` 16 | Database uint32 `yaml:"database" json:"database" default:"0"` 17 | MaxPoolSize uint32 `yaml:"max_pool_size" json:"max_pool_size" default:"0"` 18 | } 19 | 20 | func (cfg RedisConfig) GetClient() *redis.Client { 21 | options := &redis.Options{ 22 | Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), 23 | Password: string(cfg.Password), 24 | DB: int(cfg.Database), 25 | PoolSize: int(cfg.MaxPoolSize), 26 | MaintNotificationsConfig: &maintnotifications.Config{ 27 | Mode: maintnotifications.ModeDisabled, 28 | }, 29 | } 30 | return redis.NewClient(options) 31 | } 32 | 33 | func (cfg RedisConfig) Validate() error { 34 | if cfg.Port > 65535 { 35 | return fmt.Errorf("port must be in the range [0, 65535]") 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDefaultIfZero(t *testing.T) { 10 | tests := []struct { 11 | Input interface{} 12 | Default interface{} 13 | Expected interface{} 14 | }{ 15 | { 16 | Input: "", 17 | Default: "value", 18 | Expected: "value", 19 | }, 20 | { 21 | Input: false, 22 | Default: true, 23 | Expected: true, 24 | }, 25 | { 26 | Input: 0, 27 | Default: 1, 28 | Expected: 1, 29 | }, 30 | } 31 | 32 | for _, test := range tests { 33 | v := DefaultIfZero(test.Input, test.Default) 34 | assert.Equal(t, test.Expected, v) 35 | } 36 | } 37 | 38 | func TestMergeMap(t *testing.T) { 39 | dst := map[string]interface{}{ 40 | "key": "v", 41 | "map": map[string]interface{}{ 42 | "k1": "v1", 43 | "k2": "v2", 44 | }, 45 | } 46 | src := map[string]interface{}{ 47 | "key": "value", 48 | "map": map[string]interface{}{ 49 | "k2": "vv2", 50 | "k3": "v3", 51 | }, 52 | } 53 | MergeMap(dst, src) 54 | assert.EqualValues(t, map[string]interface{}{ 55 | "key": "value", 56 | "map": map[string]interface{}{ 57 | "k1": "v1", 58 | "k2": "vv2", 59 | "k3": "v3", 60 | }, 61 | }, dst) 62 | } 63 | -------------------------------------------------------------------------------- /config/providers/env.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/webhookx-io/webhookx/pkg/envconfig" 7 | "github.com/webhookx-io/webhookx/pkg/secret" 8 | "github.com/webhookx-io/webhookx/pkg/secret/reference" 9 | ) 10 | 11 | type EnvProvider struct { 12 | prefix string 13 | manager *secret.SecretManager 14 | } 15 | 16 | func (p *EnvProvider) WithManager(manager *secret.SecretManager) *EnvProvider { 17 | p.manager = manager 18 | return p 19 | } 20 | 21 | func (p *EnvProvider) Load(cfg any) error { 22 | var reader = envconfig.EnvironmentReader 23 | if p.manager != nil { 24 | reader = func(key string) (string, bool, error) { 25 | value, ok, _ := envconfig.EnvironmentReader.Read(key) 26 | if ok && reference.IsReference(value) { 27 | ref, err := reference.Parse(value) 28 | if err != nil { 29 | return "", false, err 30 | } 31 | resolved, err := p.manager.ResolveReference(context.TODO(), ref) 32 | if err != nil { 33 | return "", false, err 34 | } 35 | value = resolved 36 | } 37 | return value, ok, nil 38 | } 39 | } 40 | 41 | return envconfig.ProcessWithReader(p.prefix, cfg, reader) 42 | } 43 | 44 | func NewEnvProvider(prefix string) *EnvProvider { 45 | return &EnvProvider{prefix: prefix} 46 | } 47 | -------------------------------------------------------------------------------- /pkg/types/time.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | type Time struct { 11 | time.Time 12 | } 13 | 14 | func NewTime(t time.Time) Time { 15 | return Time{ 16 | Time: t, 17 | } 18 | } 19 | 20 | func (t Time) Equal(other Time) bool { 21 | return t.Time.Equal(other.Time) 22 | } 23 | 24 | func (t *Time) UnmarshalJSON(b []byte) error { 25 | var timestamp int64 26 | err := json.Unmarshal(b, ×tamp) 27 | if err != nil { 28 | return err 29 | } 30 | if timestamp != 0 { 31 | t.Time = time.UnixMilli(timestamp) 32 | } 33 | return nil 34 | } 35 | 36 | func (t Time) MarshalJSON() ([]byte, error) { 37 | if t.IsZero() { 38 | return []byte("0"), nil 39 | } 40 | return []byte(fmt.Sprintf("%d", t.UnixMilli())), nil 41 | } 42 | 43 | func (t Time) MarshalYAML() (interface{}, error) { 44 | return t.UnixMilli(), nil 45 | } 46 | 47 | func (t *Time) Scan(src interface{}) error { 48 | if src == nil { 49 | t.Time = time.Unix(0, 0) 50 | return nil 51 | } 52 | 53 | if v, ok := src.(time.Time); ok { 54 | t.Time = v 55 | return nil 56 | } else { 57 | return fmt.Errorf("cannot scan %T", src) 58 | } 59 | } 60 | 61 | func (t Time) Value() (driver.Value, error) { 62 | return t.Time, nil 63 | } 64 | -------------------------------------------------------------------------------- /test/fixtures/mtls/client.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDRzCCAi+gAwIBAgIBATANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJBVTET 3 | MBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQ 4 | dHkgTHRkMB4XDTI1MTAyNzA5NTkwNVoXDTM1MTAyNTA5NTkwNVowRTELMAkGA1UE 5 | BhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdp 6 | ZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALi6 7 | P8bno65jxADyS8UhourkAJCn41Vr7B2DDSY/1TuvTL8KyOGxqN+Au2frYORD6UKA 8 | zoPQeCk+69f161MxX1EAoxDUbGm/3A/3U19vc27TZfX6dF5fUu1XYmF56740FDgB 9 | 5mJ92Fw/C9zMxokZDqLr9hWUqHu9Y2U2HH81y4IWFgRhIgp9x5RSB3/oGkgcXw/J 10 | QrHgu1HR5iGGJReH8yFgXMBrvHH6i1PZGOM499Cvy9v6e6q/FjxFsf5i4zDJGoBQ 11 | 1Mjx+q3jx3XZomLr7L248KkbXT3LiqN+UABNYj0j8NAFeekk3QVerHQ9mguHd/Wf 12 | ncdugqMaHZqGXns5y+MCAwEAAaNCMEAwHQYDVR0OBBYEFDXwbms6qniw8eOdZBDX 13 | vec0amwjMB8GA1UdIwQYMBaAFAdfmW9t/hyQ+Nv3eLAjBoqA8UxGMA0GCSqGSIb3 14 | DQEBCwUAA4IBAQC8m0CzKU6mZCUXCrdL4mbkEbFM4NyuzcxlYLk6d2YW0W6W3mGT 15 | TvvSdxevGv8e5KZ6QsXncsosyt3rKJ2o3Fsw2HMXwMRV9JgoYUWgSg4pg64leDCj 16 | Ib1OcZqo2hNUufJrBxPZHGBV8DwgkA5wXunKD8HVuj+zYD8EkSo0IJx6/tNfTHYc 17 | aAUfkkyWp78bSF0A23I5ovtc2qnorc6mvVO3wWAZ03JekyWRGFA6nXuP/ciX+l4X 18 | 7xzkOOHxMwQUpoqFn9pLZTu7ZYF3Bfy0UI2LulCyS0L+u1+4/Xd+IZ1X71/ohi/v 19 | lFuQg8lZ2evt/m2Pu9LXCmilYHfH4jop/1RL 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /plugins/wasm/README.md: -------------------------------------------------------------------------------- 1 | # Wasm Plugin 2 | 3 | This plugin allows you to customize delivery requests including URL, method, headers, and payload. 4 | 5 | The Application Binary Interface (ABI) is defined in [versions](./versions). 6 | 7 | For more examples, please see [examples/wasm](/examples/wasm). 8 | 9 | 10 | ### Configuration 11 | 12 | | Name | Type | Description | 13 | |-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| 14 | | `file`
*required\** | string | The filename of wasm module. | 15 | | `envs`
*optional* | map | The environment variables that are exposed to the wasm module. | 16 | 17 | 18 | 19 | ### Configuration examples 20 | 21 | ```yaml 22 | name: wasm 23 | enabled: true 24 | config: 25 | file: /path/to/your.wasm 26 | envs: 27 | foo: bar 28 | ``` 29 | 30 | -------------------------------------------------------------------------------- /cmd/start.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/webhookx-io/webhookx/app" 11 | "github.com/webhookx-io/webhookx/pkg/license" 12 | ) 13 | 14 | func newStartCmd() *cobra.Command { 15 | start := &cobra.Command{ 16 | Use: "start", 17 | Short: "Start server", 18 | Long: ``, 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | lic, err := license.Load() 21 | if err != nil { 22 | return err 23 | } 24 | license.SetLicenser(license.NewLicenser(lic)) 25 | 26 | cfg, err := initConfig(configurationFile) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | app, err := app.New(cfg) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 37 | go func() { 38 | <-ctx.Done() 39 | err = app.Stop() 40 | if err != nil { 41 | os.Exit(1) 42 | } 43 | }() 44 | 45 | if err := app.Start(); err != nil { 46 | return err 47 | } 48 | 49 | app.Wait() 50 | 51 | return nil 52 | }, 53 | } 54 | 55 | start.PersistentFlags().StringVarP(&configurationFile, "config", "", "", "The configuration filename") 56 | 57 | return start 58 | } 59 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/spf13/cobra" 8 | "github.com/webhookx-io/webhookx/config" 9 | ) 10 | 11 | var ( 12 | AdminURL = "http://localhost:9601" 13 | ) 14 | 15 | var ( 16 | configurationFile string 17 | verbose bool 18 | ) 19 | 20 | func initConfig(filename string) (*config.Config, error) { 21 | cfg := config.New() 22 | if err := config.Load(filename, cfg); err != nil { 23 | return nil, errors.Wrap(err, "could not load configuration") 24 | } 25 | 26 | if err := cfg.Validate(); err != nil { 27 | return nil, errors.Wrap(err, "invalid configuration") 28 | } 29 | return cfg, nil 30 | } 31 | 32 | func NewRootCmd() *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: "webhookx", 35 | Short: "", 36 | Long: ``, 37 | SilenceUsage: true, 38 | } 39 | 40 | cmd.SetOut(os.Stdout) 41 | cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "", false, "Verbose logging.") 42 | 43 | cmd.AddCommand(newVersionCmd()) 44 | cmd.AddCommand(newDatabaseCmd()) 45 | cmd.AddCommand(newStartCmd()) 46 | cmd.AddCommand(newAdminCmd()) 47 | 48 | return cmd 49 | } 50 | 51 | func Execute() { 52 | rootCmd := NewRootCmd() 53 | if err := rootCmd.Execute(); err != nil { 54 | os.Exit(1) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/accesslog/middleware.go: -------------------------------------------------------------------------------- 1 | package accesslog 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | type middleware struct { 9 | logger AccessLogger 10 | } 11 | 12 | func NewMiddleware(logger AccessLogger) func(http.Handler) http.Handler { 13 | h := middleware{ 14 | logger: logger, 15 | } 16 | 17 | return func(next http.Handler) http.Handler { 18 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | h.ServeHTTP(w, r, next) 20 | }) 21 | } 22 | } 23 | 24 | func (m *middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Handler) { 25 | entry := NewEntry(r) 26 | 27 | now := time.Now() 28 | rw := &responseWriter{ResponseWriter: w} 29 | next.ServeHTTP(rw, r) 30 | 31 | entry.Latency = time.Since(now) 32 | entry.Response.Status = rw.statusCode 33 | entry.Response.Size = rw.bytesWritten 34 | 35 | m.logger.Log(entry) 36 | } 37 | 38 | type responseWriter struct { 39 | http.ResponseWriter 40 | statusCode int 41 | bytesWritten int 42 | } 43 | 44 | func (w *responseWriter) WriteHeader(statusCode int) { 45 | w.statusCode = statusCode 46 | w.ResponseWriter.WriteHeader(statusCode) 47 | } 48 | 49 | func (w *responseWriter) Write(b []byte) (int, error) { 50 | n, err := w.ResponseWriter.Write(b) 51 | w.bytesWritten += n 52 | return n, err 53 | } 54 | -------------------------------------------------------------------------------- /db/dao/plugin_dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/webhookx-io/webhookx/constants" 8 | "github.com/webhookx-io/webhookx/db/entities" 9 | "github.com/webhookx-io/webhookx/db/query" 10 | "github.com/webhookx-io/webhookx/eventbus" 11 | "github.com/webhookx-io/webhookx/utils" 12 | ) 13 | 14 | type pluginDAO struct { 15 | *DAO[entities.Plugin] 16 | } 17 | 18 | func NewPluginDAO(db *sqlx.DB, bus *eventbus.EventBus, workspace bool) PluginDAO { 19 | opts := Options{ 20 | Table: "plugins", 21 | EntityName: "plugin", 22 | Workspace: workspace, 23 | CachePropagate: true, 24 | CacheName: constants.PluginCacheKey.Name, 25 | } 26 | return &pluginDAO{ 27 | DAO: NewDAO[entities.Plugin](db, bus, opts), 28 | } 29 | } 30 | 31 | func (dao *pluginDAO) ListEndpointPlugin(ctx context.Context, endpointId string) ([]*entities.Plugin, error) { 32 | q := query.PluginQuery{} 33 | q.EndpointId = &endpointId 34 | q.Enabled = utils.Pointer(true) 35 | return dao.List(ctx, &q) 36 | } 37 | 38 | func (dao *pluginDAO) ListSourcePlugin(ctx context.Context, sourceId string) ([]*entities.Plugin, error) { 39 | q := query.PluginQuery{} 40 | q.SourceId = &sourceId 41 | q.Enabled = utils.Pointer(true) 42 | return dao.List(ctx, &q) 43 | } 44 | -------------------------------------------------------------------------------- /test/cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/webhookx-io/webhookx/config" 12 | "github.com/webhookx-io/webhookx/pkg/cache" 13 | ) 14 | 15 | var _ = Describe("cache", Ordered, func() { 16 | 17 | var redisCache cache.Cache 18 | 19 | BeforeAll(func() { 20 | cfg := config.New() 21 | err := config.Load("", cfg) 22 | assert.NoError(GinkgoT(), err) 23 | redisCache = cache.NewRedisCache(cfg.Redis.GetClient()) 24 | }) 25 | 26 | It("sanity", func() { 27 | ctx := context.TODO() 28 | 29 | err := redisCache.Put(ctx, "foo", "bar", time.Second*5) 30 | assert.NoError(GinkgoT(), err) 31 | 32 | var value string 33 | exist, err := redisCache.Get(ctx, "foo", &value) 34 | assert.NoError(GinkgoT(), err) 35 | assert.True(GinkgoT(), exist) 36 | assert.Equal(GinkgoT(), "bar", value) 37 | 38 | err = redisCache.Remove(ctx, "foo") 39 | assert.NoError(GinkgoT(), err) 40 | 41 | exist, err = redisCache.Get(ctx, "foo", &value) 42 | assert.NoError(GinkgoT(), err) 43 | assert.False(GinkgoT(), exist) 44 | }) 45 | }) 46 | 47 | func TestCache(t *testing.T) { 48 | RegisterFailHandler(Fail) 49 | RunSpecs(t, "Cache Suite") 50 | } 51 | -------------------------------------------------------------------------------- /utils/validate_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type NestB struct { 11 | Timeout int `validate:"gt=0"` 12 | } 13 | 14 | type NestA struct { 15 | Gender string `validate:"oneof=male female"` 16 | NestB NestB 17 | } 18 | 19 | type Struct struct { 20 | ID string `json:"id"` 21 | Name string `validate:"required"` 22 | Nest NestA 23 | Age int `validate:"gte=0,lte=100"` 24 | Pets []string `validate:"min=1"` 25 | } 26 | 27 | func TestValidate(t *testing.T) { 28 | err := Validate(&Struct{ 29 | Name: "", 30 | Nest: NestA{ 31 | Gender: "x", 32 | NestB: NestB{ 33 | Timeout: 0, 34 | }, 35 | }, 36 | Age: -1, 37 | Pets: nil, 38 | }) 39 | bytes, e := json.MarshalIndent(err, "", " ") 40 | assert.NoError(t, e) 41 | expected := ` 42 | { 43 | "message": "request validation", 44 | "fields": { 45 | "Age": "value must be >= 0", 46 | "Name": "required field missing", 47 | "Nest": { 48 | "Gender": "value must be one of: [male, female]", 49 | "NestB": { 50 | "Timeout": "value must be > 0" 51 | } 52 | }, 53 | "Pets": "length must be at least 1" 54 | } 55 | } 56 | ` 57 | assert.JSONEq(t, expected, string(bytes)) 58 | } 59 | -------------------------------------------------------------------------------- /test/fixtures/mtls/client-ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDazCCAlOgAwIBAgIUYQOjr8UTj94da6sV/oeLhC2Ew3EwDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTEwMjcwOTU4NTdaFw0zNTEw 5 | MjUwOTU4NTdaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 6 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB 7 | AQUAA4IBDwAwggEKAoIBAQDkDK/kY66ADvClFR7CH+W3PquvrSBgybse1szv6FIX 8 | QJDQhd5lWgf8Q0MnbA/c1l8QFNKhVqI1EP5ld3O7Ag8mJfzFqeuJRLE7wEFi06Q2 9 | k9HJMhDoHB6r8ZucVhNbih9oIdna8wpwfhunGT1VZvge7XsfylMrVxPQ9CCSOPGO 10 | umYrYnQarzFIAT82ztHMI8hGwLAKHWMNl7kA7WBL6fsuRYgq85KDo7dXdVhmavBt 11 | m2pzF5NofTmarVM797aN8DC4v1oGByyfKchTopiwrLynMehm7H2HwaWs8vGcs7+s 12 | BfRqVP7b14x4f4ckUwPwrcekePU2FLQRFXQIQ/PL2o0rAgMBAAGjUzBRMB0GA1Ud 13 | DgQWBBQHX5lvbf4ckPjb93iwIwaKgPFMRjAfBgNVHSMEGDAWgBQHX5lvbf4ckPjb 14 | 93iwIwaKgPFMRjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQDP 15 | QZoWuCLTaccvCJmzX+GW+kbQmU/3SwCSqUqfM5pa/QLvNbbS0Y1BKYL4TglfRfa7 16 | koaNmCxRkwBlB6P4WN/uLQm1AOKDOQPxcnPQ9SKt9AX+HRwcAczsrzuUZQvpHK1H 17 | V12Lya/TibKoZmzspS+w5j7Bpoi2xkid3lj+p03fi05dGdXGY+EVVLvjzMydXGon 18 | IOcD26TTbkv/aXbUZFVz05p2UBOGE3rEFop+CUiFqRcpOzYXrpC2q68XAJHg8uMG 19 | a86av5ssM0hFbb9czweIqcJmsb/Osj7VEUQufX5dUnko1A8U4bKsQLzeR9Z4+rjq 20 | X/+30inPRZf1kmufXOww 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /test/fixtures/mtls/server-ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDazCCAlOgAwIBAgIUNqUgZJu3fal+yfWlhhpn47cobkcwDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTEwMjcwOTU4NTJaFw0zNTEw 5 | MjUwOTU4NTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 6 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB 7 | AQUAA4IBDwAwggEKAoIBAQC1eoyewu/5rU4okGNwtt233HN9mSlIx6SmRh3y6KfY 8 | Rl/RZ1z1+Ghcc/1XctJmb7K9tVXyBzRN+83eT/FXP0whqmCAaY8h0CNgJkOO3Oo0 9 | 4UN0dPZYrvexR7A0KmHUHxD/o9W6eszKtXYluY0Gv0V9KKZtU6dPl+k4TRIPTmdC 10 | DjujIMyw2obQob1Y0KQVW8VUGj/VLGlwjdZ7QEaZ7tonRMmLyQmscgS/R1sbXf4B 11 | zma7nxaxCL5rbSoSOTF/6kgzT70hGE8LoHsZxo4v9y3DlcVbszfQJDj0bdGEIYBM 12 | qXEtbPhvIZMqQuoNfP1ebw9NJ+M8mRPuyC8dm7167IkPAgMBAAGjUzBRMB0GA1Ud 13 | DgQWBBQWj+oDMvcqDSrxNnK7CbkdPqqeAjAfBgNVHSMEGDAWgBQWj+oDMvcqDSrx 14 | NnK7CbkdPqqeAjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAN 15 | ob2pVAtmTJiEQDWcFcP5kb+W0IANYfhXxZtTn3YDmy66zgQzVKi8mh1DECXe+g8m 16 | AaAap9zclD+YUsprCatBVPhhC1QBcxgQEL+WE4D2uQM+nck/XqH5Okwav/TJeReH 17 | kw38QRLVTMjnx8GkZE5w5RIWq+mgUm2A/O1pCaAESVqgP3If9AV0GBPW6MzNGKac 18 | fmikMFvgl20Ou0RIfBsGaSUkxzcipH5hx/CSS0NnVZxfGPn4QFQp/8T7FAR0uFQT 19 | YbC8V3p/6ji2xV+ZdEpxMbOcjaLzqXExKYC26cwm9vm+YtYEcihN4ZcbRQF9QPiL 20 | +fOKD702GxSEafU/pg/a 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /pkg/license/licenser.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/webhookx-io/webhookx/api/license" 7 | ) 8 | 9 | type Licenser interface { 10 | Allow(feature string) bool 11 | AllowAPI(workspace string, path string, method string) bool 12 | License() *license.License 13 | } 14 | 15 | var _ Licenser = &DefaultLicenser{} 16 | 17 | type DefaultLicenser struct { 18 | license *license.License 19 | } 20 | 21 | func NewLicenser(license *license.License) *DefaultLicenser { 22 | return &DefaultLicenser{ 23 | license: license, 24 | } 25 | } 26 | 27 | func (l *DefaultLicenser) Allow(feature string) bool { 28 | plan := l.plan() 29 | return plans[plan].HasFeature(feature) 30 | } 31 | 32 | func (l *DefaultLicenser) AllowAPI(workspace string, path string, method string) bool { 33 | plan := l.plan() 34 | if api := plans[plan].ForbiddenAPIs[path]; api != nil { 35 | if slices.Contains(api.Methods, method) { 36 | if !api.ExcludeDefaultWorkspace { 37 | return false 38 | } 39 | if workspace != "default" { 40 | return false 41 | } 42 | } 43 | } 44 | return true 45 | } 46 | 47 | func (l *DefaultLicenser) License() *license.License { 48 | return l.license 49 | } 50 | 51 | func (l *DefaultLicenser) plan() string { 52 | if l.license.Expired() { 53 | return "free" 54 | } 55 | return l.license.Plan 56 | } 57 | -------------------------------------------------------------------------------- /pkg/plugin/plugin.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type Plugin interface { 9 | // Name returns plugin's name 10 | Name() string 11 | 12 | // Init inits plugin with configuration 13 | Init(config map[string]interface{}) error 14 | 15 | // GetConfig returns plugin's configuration 16 | GetConfig() map[string]interface{} 17 | 18 | // ValidateConfig validates plugin's configuration 19 | ValidateConfig(config map[string]interface{}) error 20 | 21 | // ExecuteInbound executes inbound 22 | ExecuteInbound(ctx context.Context, inbound *Inbound) (InboundResult, error) 23 | 24 | // ExecuteOutbound executes outbound 25 | ExecuteOutbound(ctx context.Context, outbound *Outbound) error 26 | } 27 | 28 | func New(name string) (Plugin, bool) { 29 | r := GetRegistration(name) 30 | if r == nil { 31 | return nil, false 32 | } 33 | return r.Factory(), true 34 | } 35 | 36 | type Outbound struct { 37 | URL string `json:"url"` 38 | Method string `json:"method"` 39 | Headers map[string]string `json:"headers"` 40 | Payload string `json:"payload"` 41 | } 42 | 43 | type Inbound struct { 44 | Request *http.Request 45 | Response http.ResponseWriter 46 | RawBody []byte 47 | } 48 | 49 | type InboundResult struct { 50 | Terminated bool 51 | Payload []byte 52 | } 53 | -------------------------------------------------------------------------------- /test/fixtures/mtls/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDdTCCAl2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJBVTET 3 | MBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQ 4 | dHkgTHRkMB4XDTI1MTAyNzA5NTkwNVoXDTM1MTAyNTA5NTkwNVowRTELMAkGA1UE 5 | BhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdp 6 | ZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJcS 7 | 6xJKxZPM766wGf5ytOpOTXfAy5HEgp8U9gF69XSAMnDJV+9Cz0r5WKGR3YRbTyXn 8 | f5tufeYoBsFMbsSYwVJImZKJSno+9blnU6fta7jrwNgorN2/BcjxrgcHVMgTlw3u 9 | yP5c+A9qKt+KjrHsJoD9Xow5ZcxPqzY0pB+Sv1UXaWz9Iyt841sVjvt4XEBHwMUW 10 | oLjQ5DkvsLdMed/rN1xnBv4GzfOm+0rI3b4gQH+37uyaCQFKXPC14IdAjiAvVI4h 11 | YSkt9wsHhzm3dbBlBV/JfrWcPYrKkPyTpuaB6YOBkprNTkQjB2cSx4xTWhPvNYti 12 | DyYKhP7o46xCNMaZlBkCAwEAAaNwMG4wHwYDVR0jBBgwFoAUFo/qAzL3Kg0q8TZy 13 | uwm5HT6qngIwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwFAYDVR0RBA0wC4IJbG9j 14 | YWxob3N0MB0GA1UdDgQWBBRJPI8RYUtw5k5oF6TJPM1ZlNMm/DANBgkqhkiG9w0B 15 | AQsFAAOCAQEANrGYVlBmC0X4/oTtHRyJRhIG7WVFJscrTIq0dSYFGg1JCPf7STyh 16 | fL02/JQ5Kfg9OVt/8T0/eZsVON+EPME0nt2LOaMeEyDwSW/pUj/BqrmZ+JHgkALS 17 | xJtPrV1oumzgkf2fZXHKKfYHiy/jk1hbFUnhZpl82XpoXGHtGxHJrlWsFhGd9YB/ 18 | kWBDR+Yk4Yc8DYDRuVOTPPyCj09bdoPSHQd+fVs09YLQ2o6gYGA9zeCf0iv7+1se 19 | q3viQhtmZ7l4Oo4YY8O4UOq3XZaYymR440qnOmw9W3t16cnVnD5DtIKLfck/trKm 20 | LubuuUEGsHMfCWpPZBRhegGOZv5hldJJRA== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /pkg/cache/redis.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/redis/go-redis/v9" 9 | "github.com/webhookx-io/webhookx/pkg/serializer" 10 | ) 11 | 12 | type RedisCache struct { 13 | c *redis.Client 14 | s serializer.Serializer 15 | } 16 | 17 | func NewRedisCache(client *redis.Client) *RedisCache { 18 | return &RedisCache{ 19 | c: client, 20 | s: serializer.MsgPack, 21 | } 22 | } 23 | 24 | func (s *RedisCache) Put(ctx context.Context, key string, value interface{}, expiration time.Duration) error { 25 | if value == nil { 26 | return nil 27 | } 28 | b, err := s.s.Serialize(value) 29 | if err != nil { 30 | return err 31 | } 32 | return s.c.Set(ctx, key, b, expiration).Err() 33 | } 34 | 35 | func (s *RedisCache) Get(ctx context.Context, key string, value interface{}) (exist bool, err error) { 36 | result, err := s.c.Get(ctx, key).Bytes() 37 | if err != nil { 38 | if errors.Is(err, redis.Nil) { 39 | return false, nil 40 | } 41 | return false, err 42 | } 43 | return true, s.s.Deserialize(result, value) 44 | } 45 | 46 | func (s *RedisCache) Remove(ctx context.Context, key string) error { 47 | return s.c.Del(ctx, key).Err() 48 | } 49 | 50 | func (s *RedisCache) Exist(ctx context.Context, key string) (bool, error) { 51 | result, err := s.c.Exists(ctx, key).Result() 52 | return result == 1, err 53 | } 54 | -------------------------------------------------------------------------------- /test/cfg/cfg_test.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/webhookx-io/webhookx/app" 11 | "github.com/webhookx-io/webhookx/test/helper" 12 | "github.com/webhookx-io/webhookx/utils" 13 | ) 14 | 15 | var _ = Describe("Configuration", Ordered, func() { 16 | 17 | var app *app.Application 18 | 19 | BeforeAll(func() { 20 | app = utils.Must(helper.Start(map[string]string{ 21 | "WEBHOOKX_DATABASE_MAX_POOL_SIZE": "0", 22 | "WEBHOOKX_DATABASE_MAX_LIFETIME": "3600", 23 | "WEBHOOKX_DATABASE_PARAMETERS": "application_name=foo&sslmode=disable&connect_timeout=30", 24 | })) 25 | }) 26 | 27 | AfterAll(func() { 28 | app.Stop() 29 | }) 30 | 31 | It("database configuration", func() { 32 | assert.EqualValues(GinkgoT(), 0, app.Config().Database.MaxPoolSize) 33 | assert.EqualValues(GinkgoT(), 3600, app.Config().Database.MaxLifetime) 34 | assert.Equal(GinkgoT(), "application_name=foo&sslmode=disable&connect_timeout=30", app.Config().Database.Parameters) 35 | assert.True(GinkgoT(), strings.HasSuffix(app.Config().Database.GetDSN(), app.Config().Database.Parameters)) 36 | }) 37 | 38 | }) 39 | 40 | func TestConfiguration(t *testing.T) { 41 | RegisterFailHandler(Fail) 42 | RunSpecs(t, "Configuration Suite") 43 | } 44 | -------------------------------------------------------------------------------- /test/cmd/start_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/webhookx-io/webhookx/test" 7 | "github.com/webhookx-io/webhookx/test/helper" 8 | ) 9 | 10 | var _ = Describe("start", Ordered, func() { 11 | Context("errors", func() { 12 | It("should return error when configuration file is invalid", func() { 13 | output, err := helper.ExecAppCommand("start", "--config", "config.yml") 14 | assert.NotNil(GinkgoT(), err) 15 | assert.Equal(GinkgoT(), "Error: could not load configuration: open config.yml: no such file or directory\n", output) 16 | }) 17 | 18 | It("should return error when configuration file is invalid", func() { 19 | output, err := helper.ExecAppCommand("start", "--config", test.FilePath("fixtures/malformed-config.yml")) 20 | assert.NotNil(GinkgoT(), err) 21 | assert.Equal(GinkgoT(), "Error: could not load configuration: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `👻` into config.Config\n", output) 22 | }) 23 | 24 | It("should return error when configuration is invalid", func() { 25 | output, err := helper.ExecAppCommand("start", "--config", test.FilePath("fixtures/invalid-config.yml")) 26 | assert.NotNil(GinkgoT(), err) 27 | assert.Equal(GinkgoT(), "Error: invalid configuration: port must be in the range [0, 65535]\n", output) 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /config/modules/tracing.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "slices" 7 | 8 | "github.com/webhookx-io/webhookx/config/types" 9 | ) 10 | 11 | type TracingConfig struct { 12 | BaseConfig 13 | Enabled bool `yaml:"enabled" json:"enabled" default:"false"` 14 | Attributes types.Map `yaml:"attributes" json:"attributes"` 15 | Opentelemetry OpentelemetryTracing `yaml:"opentelemetry" json:"opentelemetry"` 16 | SamplingRate float64 `yaml:"sampling_rate" json:"sampling_rate" default:"1.0" envconfig:"SAMPLING_RATE"` 17 | } 18 | 19 | type OpentelemetryTracing struct { 20 | Protocol OtlpProtocol `yaml:"protocol" json:"protocol" envconfig:"PROTOCOL" default:"http/protobuf"` 21 | Endpoint string `yaml:"endpoint" json:"endpoint" envconfig:"ENDPOINT" default:"http://127.0.0.1:4318/v1/traces"` 22 | } 23 | 24 | func (cfg OpentelemetryTracing) Validate() error { 25 | if !slices.Contains([]OtlpProtocol{OtlpProtocolGRPC, OtlpProtocolHTTP}, cfg.Protocol) { 26 | return fmt.Errorf("invalid protocol: %s", cfg.Protocol) 27 | } 28 | return nil 29 | } 30 | 31 | func (cfg TracingConfig) Validate() error { 32 | if cfg.SamplingRate > 1 || cfg.SamplingRate < 0 { 33 | return errors.New("sampling_rate must be in the range [0, 1]") 34 | } 35 | if err := cfg.Opentelemetry.Validate(); err != nil { 36 | return err 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/http/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/webhookx-io/webhookx/constants" 8 | ) 9 | 10 | func JSON(w http.ResponseWriter, code int, data interface{}) { 11 | _json(w, code, data, false) 12 | } 13 | 14 | func _json(w http.ResponseWriter, code int, data interface{}, pretty bool) { 15 | for _, header := range constants.DefaultResponseHeaders { 16 | w.Header().Set(header.Name, header.Value) 17 | } 18 | 19 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 20 | 21 | w.WriteHeader(code) 22 | 23 | if data == nil { 24 | return 25 | } 26 | 27 | var bytes []byte 28 | switch v := data.(type) { 29 | case string: 30 | bytes = []byte(v) 31 | default: 32 | var err error 33 | if pretty { 34 | bytes, err = json.MarshalIndent(data, "", " ") 35 | } else { 36 | bytes, err = json.Marshal(data) 37 | } 38 | if err != nil { 39 | panic(err) 40 | } 41 | } 42 | _, err := w.Write(bytes) 43 | if err != nil { 44 | panic(err) 45 | } 46 | } 47 | 48 | func Text(w http.ResponseWriter, code int, body string) { 49 | for _, header := range constants.DefaultResponseHeaders { 50 | w.Header().Set(header.Name, header.Value) 51 | } 52 | 53 | w.Header().Set("Content-Type", "text/plain") 54 | w.WriteHeader(code) 55 | _, err := w.Write([]byte(body)) 56 | if err != nil { 57 | panic(err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /plugins/function/sdk/sdk_utils.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/md5" 6 | "crypto/sha1" 7 | "crypto/sha256" 8 | "crypto/sha512" 9 | "crypto/subtle" 10 | "encoding/base64" 11 | "encoding/hex" 12 | "errors" 13 | "hash" 14 | ) 15 | 16 | type UtilsSDK struct{} 17 | 18 | func NewUtilsSDK() *UtilsSDK { 19 | return &UtilsSDK{} 20 | } 21 | 22 | func (sdk *UtilsSDK) Hmac(algorithm string, key string, data string) []byte { 23 | var fn func() hash.Hash 24 | switch algorithm { 25 | case "SHA-1": 26 | fn = sha1.New 27 | case "SHA-256": 28 | fn = sha256.New 29 | case "SHA-512": 30 | fn = sha512.New 31 | case "MD5": 32 | fn = md5.New 33 | default: 34 | panic(errors.New("unknown algorithm: " + algorithm)) 35 | } 36 | mac := hmac.New(fn, []byte(key)) 37 | mac.Write([]byte(data)) 38 | return mac.Sum(nil) 39 | } 40 | 41 | func (sdk *UtilsSDK) Encode(encoding string, data []byte) string { 42 | switch encoding { 43 | case "hex": 44 | return hex.EncodeToString(data) 45 | case "base64": 46 | return base64.StdEncoding.EncodeToString(data) 47 | case "base64url": 48 | return base64.RawURLEncoding.EncodeToString(data) 49 | default: 50 | panic(errors.New("unknown encode type: " + encoding)) 51 | } 52 | } 53 | 54 | func (sdk *UtilsSDK) TimingSafeEqual(str1 string, str2 string) bool { 55 | return subtle.ConstantTimeCompare([]byte(str1), []byte(str2)) == 1 56 | } 57 | -------------------------------------------------------------------------------- /pkg/plugin/base.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/mitchellh/mapstructure" 8 | "github.com/webhookx-io/webhookx/pkg/openapi" 9 | "github.com/webhookx-io/webhookx/utils" 10 | ) 11 | 12 | // Configuration plugin configuration 13 | type Configuration interface { 14 | Schema() *openapi3.Schema 15 | } 16 | 17 | type BasePlugin[T Configuration] struct { 18 | Config T 19 | } 20 | 21 | func (p *BasePlugin[T]) Init(config map[string]interface{}) error { 22 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 23 | TagName: "json", 24 | Result: &p.Config, 25 | }) 26 | if err != nil { 27 | return err 28 | } 29 | return decoder.Decode(config) 30 | } 31 | 32 | func (p *BasePlugin[T]) GetConfig() map[string]interface{} { 33 | m, err := utils.StructToMap(p.Config) 34 | if err != nil { 35 | panic(err) 36 | } 37 | return m 38 | } 39 | 40 | func (p *BasePlugin[T]) ValidateConfig(config map[string]interface{}) error { 41 | err := openapi.Validate(p.Config.Schema(), config) 42 | if err != nil { 43 | return err 44 | } 45 | return nil 46 | } 47 | 48 | func (p *BasePlugin[T]) ExecuteOutbound(ctx context.Context, outbound *Outbound) error { 49 | panic("not implemented") 50 | } 51 | 52 | func (p *BasePlugin[T]) ExecuteInbound(ctx context.Context, inbound *Inbound) (InboundResult, error) { 53 | panic("not implemented") 54 | } 55 | -------------------------------------------------------------------------------- /constants/cache_key.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "strings" 4 | 5 | // CacheKey cache key definition. 6 | // format "webhookx:::" 7 | type CacheKey struct { 8 | Name string 9 | Version string 10 | } 11 | 12 | func (c CacheKey) Build(id string) string { 13 | var sb strings.Builder 14 | sb.WriteString("webhookx:") 15 | sb.WriteString(c.Name) 16 | sb.WriteString(":") 17 | sb.WriteString(c.Version) 18 | sb.WriteString(":") 19 | sb.WriteString(id) 20 | return sb.String() 21 | } 22 | 23 | var ( 24 | EventCacheKey = register(CacheKey{"events", "v1"}) 25 | EndpointCacheKey = register(CacheKey{"endpoints", "v1"}) 26 | EndpointPluginsKey = register(CacheKey{"endpoint_plugins", "v1"}) 27 | SourcePluginsKey = register(CacheKey{"source_plugins", "v1"}) 28 | SourceCacheKey = register(CacheKey{"sources", "v1"}) 29 | WorkspaceCacheKey = register(CacheKey{"workspaces", "v1"}) 30 | AttemptCacheKey = register(CacheKey{"attempts", "v1"}) 31 | PluginCacheKey = register(CacheKey{"plugins", "v1"}) 32 | AttemptDetailCacheKey = register(CacheKey{"attempt_details", "v1"}) 33 | WorkspaceEndpointsKey = register(CacheKey{"workspaces_endpoints", "v1"}) 34 | ) 35 | 36 | var registry = map[string]CacheKey{} 37 | 38 | func register(ck CacheKey) CacheKey { 39 | registry[ck.Name] = ck 40 | return ck 41 | } 42 | 43 | func CacheKeyFrom(name string) CacheKey { 44 | return registry[name] 45 | } 46 | -------------------------------------------------------------------------------- /config/modules/database.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/webhookx-io/webhookx/config/types" 7 | ) 8 | 9 | type DatabaseConfig struct { 10 | BaseConfig 11 | Host string `yaml:"host" json:"host" default:"127.0.0.1"` 12 | Port uint32 `yaml:"port" json:"port" default:"5432"` 13 | Username string `yaml:"username" json:"username" default:"webhookx"` 14 | Password types.Password `yaml:"password" json:"password" default:""` 15 | Database string `yaml:"database" json:"database" default:"webhookx"` 16 | Parameters string `yaml:"parameters" json:"parameters" default:"application_name=webhookx&sslmode=disable&connect_timeout=10"` 17 | MaxPoolSize uint32 `yaml:"max_pool_size" json:"max_pool_size" default:"40" envconfig:"MAX_POOL_SIZE"` 18 | MaxLifetime uint32 `yaml:"max_life_time" json:"max_life_time" default:"1800" envconfig:"MAX_LIFETIME"` 19 | } 20 | 21 | func (cfg DatabaseConfig) GetDSN() string { 22 | dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s", 23 | cfg.Username, 24 | cfg.Password, 25 | cfg.Host, 26 | cfg.Port, 27 | cfg.Database, 28 | ) 29 | if len(cfg.Parameters) > 0 { 30 | dsn = fmt.Sprintf("%s?%s", dsn, cfg.Parameters) 31 | } 32 | return dsn 33 | } 34 | 35 | func (cfg DatabaseConfig) Validate() error { 36 | if cfg.Port > 65535 { 37 | return fmt.Errorf("port must be in the range [0, 65535]") 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /db/migrations/9_timestamp.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "workspaces" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3); 2 | ALTER TABLE IF EXISTS ONLY "workspaces" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3); 3 | 4 | ALTER TABLE IF EXISTS ONLY "sources" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3); 5 | ALTER TABLE IF EXISTS ONLY "sources" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3); 6 | 7 | ALTER TABLE IF EXISTS ONLY "plugins" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3); 8 | ALTER TABLE IF EXISTS ONLY "plugins" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3); 9 | 10 | ALTER TABLE IF EXISTS ONLY "events" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3); 11 | ALTER TABLE IF EXISTS ONLY "events" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3); 12 | 13 | ALTER TABLE IF EXISTS ONLY "endpoints" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3); 14 | ALTER TABLE IF EXISTS ONLY "endpoints" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3); 15 | 16 | ALTER TABLE IF EXISTS ONLY "attempts" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3); 17 | ALTER TABLE IF EXISTS ONLY "attempts" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3); 18 | 19 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3); 20 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3); 21 | -------------------------------------------------------------------------------- /plugins/function/sdk/sdk_request.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "github.com/dop251/goja" 5 | "github.com/webhookx-io/webhookx/utils" 6 | ) 7 | 8 | type RequestSDK struct { 9 | opts *Options 10 | } 11 | 12 | func NewRequestSDK(opts *Options) *RequestSDK { 13 | return &RequestSDK{ 14 | opts: opts, 15 | } 16 | } 17 | 18 | func (sdk *RequestSDK) GetHost() string { 19 | return sdk.opts.Context.HTTPRequest.R.Host 20 | } 21 | 22 | func (sdk *RequestSDK) GetMethod() string { 23 | return sdk.opts.Context.HTTPRequest.R.Method 24 | } 25 | 26 | func (sdk *RequestSDK) GetPath() string { 27 | return sdk.opts.Context.HTTPRequest.R.URL.Path 28 | } 29 | 30 | func (sdk *RequestSDK) GetHeaders() map[string]string { 31 | return utils.HeaderMap(sdk.opts.Context.HTTPRequest.R.Header) 32 | } 33 | 34 | func (sdk *RequestSDK) getHeader(name string) *string { 35 | values := sdk.opts.Context.HTTPRequest.R.Header.Values(name) 36 | if len(values) == 0 { 37 | return nil 38 | } 39 | value := values[0] 40 | return &value 41 | } 42 | 43 | func (sdk *RequestSDK) GetHeader(call goja.FunctionCall) goja.Value { 44 | name := call.Argument(0).String() 45 | value := sdk.getHeader(name) 46 | if value == nil { 47 | return goja.Null() 48 | } 49 | return sdk.opts.VM.ToValue(*value) 50 | } 51 | 52 | func (sdk *RequestSDK) GetBody() string { 53 | return string(sdk.opts.Context.HTTPRequest.Body) 54 | } 55 | 56 | func (sdk *RequestSDK) SetBody(body string) { 57 | sdk.opts.Context.HTTPRequest.Body = []byte(body) 58 | } 59 | -------------------------------------------------------------------------------- /plugins/function/plugin.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/webhookx-io/webhookx/db/entities" 8 | "github.com/webhookx-io/webhookx/pkg/plugin" 9 | "github.com/webhookx-io/webhookx/plugins/function/function" 10 | "github.com/webhookx-io/webhookx/plugins/function/sdk" 11 | ) 12 | 13 | type Config struct { 14 | Function string `json:"function"` 15 | } 16 | 17 | func (c Config) Schema() *openapi3.Schema { 18 | return entities.LookupSchema("FunctionPluginConfiguration") 19 | } 20 | 21 | type FunctionPlugin struct { 22 | plugin.BasePlugin[Config] 23 | } 24 | 25 | func (p *FunctionPlugin) Name() string { 26 | return "function" 27 | } 28 | 29 | func (p *FunctionPlugin) ExecuteInbound(ctx context.Context, inbound *plugin.Inbound) (result plugin.InboundResult, err error) { 30 | fn := function.New("javascript", p.Config.Function) 31 | 32 | req := sdk.HTTPRequest{ 33 | R: inbound.Request, 34 | Body: inbound.RawBody, 35 | } 36 | 37 | res, err := fn.Execute(&sdk.ExecutionContext{ 38 | HTTPRequest: &req, 39 | }) 40 | if err != nil { 41 | return 42 | } 43 | 44 | if res.HTTPResponse != nil { 45 | for k, v := range res.HTTPResponse.Headers { 46 | inbound.Response.Header().Set(k, v) 47 | } 48 | inbound.Response.WriteHeader(res.HTTPResponse.Code) 49 | _, _ = inbound.Response.Write([]byte(res.HTTPResponse.Body)) 50 | result.Terminated = true 51 | return 52 | } 53 | 54 | result.Payload = req.Body 55 | return 56 | } 57 | -------------------------------------------------------------------------------- /webhookx.sample.yml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: default-endpoint 3 | request: 4 | timeout: 10000 5 | url: https://httpbin.org/anything 6 | method: POST 7 | headers: 8 | x-apikey: secret 9 | retry: 10 | strategy: fixed 11 | config: 12 | attempts: [0, 3600, 3600] 13 | events: [ "charge.succeeded" ] 14 | plugins: 15 | - name: webhookx-signature 16 | config: 17 | signing_secret: foo 18 | - name: wasm 19 | enabled: false 20 | config: 21 | file: /path/to/your.wasm 22 | envs: 23 | foo: bar 24 | 25 | sources: 26 | - name: default-source 27 | type: http 28 | config: 29 | http: 30 | path: / 31 | methods: [ "POST" ] 32 | response: 33 | code: 200 34 | content_type: application/json 35 | body: '{"message": "OK"}' 36 | plugins: 37 | - name: "jsonschema-validator" 38 | config: 39 | draft: "6" 40 | schemas: 41 | charge.succeeded: 42 | schema: | 43 | { 44 | "type": "object", 45 | "properties": { 46 | "id": { "type": "string" }, 47 | "amount": { "type": "integer", "minimum": 1 }, 48 | "currency": { "type": "string", "minLength": 3, "maxLength": 6 } 49 | }, 50 | "required": ["id", "amount", "currency"] 51 | } 52 | -------------------------------------------------------------------------------- /plugins/plugins.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "github.com/webhookx-io/webhookx/pkg/plugin" 5 | basic_auth "github.com/webhookx-io/webhookx/plugins/basic-auth" 6 | "github.com/webhookx-io/webhookx/plugins/function" 7 | hmac_auth "github.com/webhookx-io/webhookx/plugins/hmac-auth" 8 | "github.com/webhookx-io/webhookx/plugins/jsonschema_validator" 9 | key_auth "github.com/webhookx-io/webhookx/plugins/key-auth" 10 | "github.com/webhookx-io/webhookx/plugins/wasm" 11 | "github.com/webhookx-io/webhookx/plugins/webhookx_signature" 12 | ) 13 | 14 | func LoadPlugins() { 15 | plugin.RegisterPlugin(plugin.TypeInbound, "function", func() plugin.Plugin { 16 | return &function.FunctionPlugin{} 17 | }) 18 | plugin.RegisterPlugin(plugin.TypeOutbound, "wasm", func() plugin.Plugin { 19 | return &wasm.WasmPlugin{} 20 | }) 21 | plugin.RegisterPlugin(plugin.TypeOutbound, "webhookx-signature", func() plugin.Plugin { 22 | return &webhookx_signature.SignaturePlugin{} 23 | }) 24 | plugin.RegisterPlugin(plugin.TypeInbound, "jsonschema-validator", func() plugin.Plugin { 25 | return &jsonschema_validator.SchemaValidatorPlugin{} 26 | }) 27 | plugin.RegisterPlugin(plugin.TypeInbound, "basic-auth", func() plugin.Plugin { 28 | return &basic_auth.BasicAuthPlugin{} 29 | }) 30 | plugin.RegisterPlugin(plugin.TypeInbound, "key-auth", func() plugin.Plugin { 31 | return &key_auth.KeyAuthPlugin{} 32 | }) 33 | plugin.RegisterPlugin(plugin.TypeInbound, "hmac-auth", func() plugin.Plugin { 34 | return &hmac_auth.HmacAuthPlugin{} 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /plugins/key-auth/plugin.go: -------------------------------------------------------------------------------- 1 | package key_auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/webhookx-io/webhookx/db/entities" 8 | "github.com/webhookx-io/webhookx/pkg/http/response" 9 | "github.com/webhookx-io/webhookx/pkg/plugin" 10 | ) 11 | 12 | type Config struct { 13 | ParamName string `json:"param_name"` 14 | ParamLocations []string `json:"param_locations"` 15 | Key string `json:"key"` 16 | } 17 | 18 | func (c Config) Schema() *openapi3.Schema { 19 | return entities.LookupSchema("KeyAuthPluginConfiguration") 20 | } 21 | 22 | type KeyAuthPlugin struct { 23 | plugin.BasePlugin[Config] 24 | } 25 | 26 | func (p *KeyAuthPlugin) Name() string { 27 | return "key-auth" 28 | } 29 | 30 | func (p *KeyAuthPlugin) ExecuteInbound(ctx context.Context, inbound *plugin.Inbound) (result plugin.InboundResult, err error) { 31 | name := p.Config.ParamName 32 | key := p.Config.Key 33 | 34 | querys := inbound.Request.URL.Query() 35 | headers := inbound.Request.Header 36 | 37 | found := false 38 | for _, source := range p.Config.ParamLocations { 39 | var value string 40 | switch source { 41 | case "query": 42 | value = querys.Get(name) 43 | case "header": 44 | value = headers.Get(name) 45 | } 46 | if value == key { 47 | found = true 48 | break 49 | } 50 | } 51 | 52 | if !found { 53 | response.JSON(inbound.Response, 401, `{"message":"Unauthorized"}`) 54 | result.Terminated = true 55 | } 56 | 57 | result.Payload = inbound.RawBody 58 | return 59 | } 60 | -------------------------------------------------------------------------------- /db/entities/source.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | type CustomResponse struct { 9 | Code int `json:"code"` 10 | ContentType string `json:"content_type" yaml:"content_type"` 11 | Body string `json:"body"` 12 | } 13 | 14 | func (m *CustomResponse) Scan(src interface{}) error { 15 | return json.Unmarshal(src.([]byte), m) 16 | } 17 | 18 | func (m CustomResponse) Value() (driver.Value, error) { 19 | return json.Marshal(m) 20 | } 21 | 22 | type SourceConfig struct { 23 | HTTP HttpSourceConfig `json:"http"` 24 | } 25 | 26 | func (m *SourceConfig) Scan(src interface{}) error { 27 | return json.Unmarshal(src.([]byte), m) 28 | } 29 | 30 | func (m SourceConfig) Value() (driver.Value, error) { 31 | return json.Marshal(m) 32 | } 33 | 34 | type HttpSourceConfig struct { 35 | Path string `json:"path"` 36 | Methods Strings `json:"methods"` 37 | Response *CustomResponse `json:"response"` 38 | } 39 | 40 | type Source struct { 41 | ID string `json:"id" db:"id"` 42 | Name *string `json:"name" db:"name"` 43 | Enabled bool `json:"enabled" db:"enabled"` 44 | Type string `json:"type" db:"type"` 45 | Config SourceConfig `json:"config" db:"config"` 46 | Async bool `json:"async" db:"async"` 47 | Metadata Metadata `json:"metadata" db:"metadata"` 48 | RateLimit *RateLimit `json:"rate_limit" yaml:"rate_limit" db:"rate_limit"` 49 | 50 | BaseModel `yaml:"-"` 51 | } 52 | 53 | func (m *Source) SchemaName() string { 54 | return "Source" 55 | } 56 | -------------------------------------------------------------------------------- /api/license/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 4 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 5 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 6 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 7 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 11 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 12 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 13 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 16 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /config/modules/metrics.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/webhookx-io/webhookx/config/types" 8 | ) 9 | 10 | type MetricsConfig struct { 11 | BaseConfig 12 | Attributes types.Map `yaml:"attributes" json:"attributes"` 13 | Exports []Export `yaml:"exports" json:"exports"` 14 | PushInterval uint32 `yaml:"push_interval" json:"push_interval" default:"10" envconfig:"PUSH_INTERVAL"` 15 | Opentelemetry OpentelemetryMetrics `yaml:"opentelemetry" json:"opentelemetry"` 16 | } 17 | 18 | type OpentelemetryMetrics struct { 19 | Protocol OtlpProtocol `yaml:"protocol" json:"protocol" envconfig:"PROTOCOL" default:"http/protobuf"` 20 | Endpoint string `yaml:"endpoint" json:"endpoint" envconfig:"ENDPOINT" default:"http://127.0.0.1:4318/v1/metrics"` 21 | } 22 | 23 | func (cfg OpentelemetryMetrics) Validate() error { 24 | if !slices.Contains([]OtlpProtocol{OtlpProtocolGRPC, OtlpProtocolHTTP}, cfg.Protocol) { 25 | return fmt.Errorf("invalid protocol: %s", cfg.Protocol) 26 | } 27 | return nil 28 | } 29 | 30 | func (cfg *MetricsConfig) Validate() error { 31 | if err := cfg.Opentelemetry.Validate(); err != nil { 32 | return err 33 | } 34 | for _, export := range cfg.Exports { 35 | if !slices.Contains([]Export{ExportOpenTelemetry}, export) { 36 | return fmt.Errorf("invalid export: %s", export) 37 | } 38 | } 39 | if cfg.PushInterval < 1 || cfg.PushInterval > 60 { 40 | return fmt.Errorf("interval must be in the range [1, 60]") 41 | } 42 | return nil 43 | } 44 | 45 | type Export string 46 | 47 | const ( 48 | ExportOpenTelemetry Export = "opentelemetry" 49 | ) 50 | -------------------------------------------------------------------------------- /pkg/secret/provider/aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/config" 9 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager" 10 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" 11 | "github.com/webhookx-io/webhookx/pkg/secret/provider" 12 | ) 13 | 14 | var ( 15 | ErrSecretNotFound = errors.New("secret not found") 16 | ) 17 | 18 | type AwsProvider struct { 19 | cfg interface{} 20 | client *secretsmanager.Client 21 | } 22 | 23 | func NewProvider(cfg map[string]interface{}) (provider.Provider, error) { 24 | opts := make([]func(*config.LoadOptions) error, 0) 25 | if region := cfg["region"].(string); region != "" { 26 | opts = append(opts, config.WithRegion(region)) 27 | } 28 | if url := cfg["url"].(string); url != "" { 29 | opts = append(opts, config.WithBaseEndpoint(url)) 30 | } 31 | awsconfig, err := config.LoadDefaultConfig(context.TODO(), opts...) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | p := &AwsProvider{ 37 | cfg: cfg, 38 | } 39 | p.client = secretsmanager.NewFromConfig(awsconfig, func(options *secretsmanager.Options) {}) 40 | 41 | return p, nil 42 | } 43 | 44 | func (p *AwsProvider) GetValue(ctx context.Context, key string, properties map[string]string) (string, error) { 45 | result, err := p.client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{SecretId: aws.String(key)}) 46 | if err != nil { 47 | var awsErr *types.ResourceNotFoundException 48 | if errors.As(err, &awsErr) { 49 | return "", ErrSecretNotFound 50 | } 51 | return "", err 52 | } 53 | return *result.SecretString, nil 54 | } 55 | -------------------------------------------------------------------------------- /db/entities/types.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | 7 | "github.com/lib/pq" 8 | "github.com/webhookx-io/webhookx/pkg/types" 9 | ) 10 | 11 | type Metadata map[string]string 12 | 13 | func (m *Metadata) Scan(src interface{}) error { 14 | return json.Unmarshal(src.([]byte), m) 15 | } 16 | 17 | func (m Metadata) Value() (driver.Value, error) { 18 | if m == nil { 19 | return []byte(`{}`), nil 20 | } 21 | return json.Marshal(m) 22 | } 23 | 24 | func (m *Metadata) UnmarshalJSON(data []byte) error { 25 | v := make(map[string]string) 26 | if err := json.Unmarshal(data, &v); err != nil { 27 | return err 28 | } 29 | *m = v 30 | return nil 31 | } 32 | 33 | type BaseModel struct { 34 | CreatedAt types.Time `db:"created_at" json:"created_at"` 35 | UpdatedAt types.Time `db:"updated_at" json:"updated_at"` 36 | WorkspaceId string `db:"ws_id" json:"-"` 37 | } 38 | 39 | type Headers map[string]string 40 | 41 | func (m *Headers) Scan(src interface{}) error { 42 | return json.Unmarshal(src.([]byte), m) 43 | } 44 | 45 | func (m Headers) Value() (driver.Value, error) { 46 | return json.Marshal(m) 47 | } 48 | 49 | func (m *Headers) UnmarshalJSON(data []byte) error { 50 | v := make(map[string]string) 51 | if err := json.Unmarshal(data, &v); err != nil { 52 | return err 53 | } 54 | *m = v 55 | return nil 56 | } 57 | 58 | type Strings = pq.StringArray 59 | 60 | type RateLimit struct { 61 | Quota int `json:"quota"` 62 | Period int `json:"period"` 63 | } 64 | 65 | func (m *RateLimit) Scan(src interface{}) error { 66 | return json.Unmarshal(src.([]byte), m) 67 | } 68 | 69 | func (m RateLimit) Value() (driver.Value, error) { 70 | return json.Marshal(m) 71 | } 72 | -------------------------------------------------------------------------------- /admin/api/attempts.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/webhookx-io/webhookx/pkg/types" 7 | 8 | "github.com/webhookx-io/webhookx/db/query" 9 | "github.com/webhookx-io/webhookx/utils" 10 | ) 11 | 12 | func (api *API) PageAttempt(w http.ResponseWriter, r *http.Request) { 13 | var q query.AttemptQuery 14 | q.Order("id", query.DESC) 15 | api.bindQuery(r, &q.Query) 16 | if r.URL.Query().Get("event_id") != "" { 17 | q.EventId = utils.Pointer(r.URL.Query().Get("event_id")) 18 | } 19 | if r.URL.Query().Get("endpoint_id") != "" { 20 | q.EndpointId = utils.Pointer(r.URL.Query().Get("endpoint_id")) 21 | } 22 | list, total, err := api.db.AttemptsWS.Page(r.Context(), &q) 23 | api.assert(err) 24 | 25 | api.json(200, w, NewPagination(total, list)) 26 | } 27 | 28 | func (api *API) GetAttempt(w http.ResponseWriter, r *http.Request) { 29 | id := api.param(r, "id") 30 | attempt, err := api.db.AttemptsWS.Get(r.Context(), id) 31 | api.assert(err) 32 | 33 | if attempt == nil { 34 | api.json(404, w, types.ErrorResponse{Message: MsgNotFound}) 35 | return 36 | } 37 | 38 | if attempt.AttemptedAt != nil { 39 | detail, err := api.db.AttemptDetailsWS.Get(r.Context(), attempt.ID) 40 | api.assert(err) 41 | if detail != nil { 42 | if detail.RequestHeaders != nil { 43 | attempt.Request.Headers = detail.RequestHeaders 44 | } 45 | if detail.RequestBody != nil { 46 | attempt.Request.Body = detail.RequestBody 47 | } 48 | if detail.ResponseHeaders != nil { 49 | attempt.Response.Headers = *detail.ResponseHeaders 50 | } 51 | if detail.ResponseBody != nil { 52 | attempt.Response.Body = detail.ResponseBody 53 | } 54 | } 55 | } 56 | 57 | api.json(200, w, attempt) 58 | } 59 | -------------------------------------------------------------------------------- /db/migrations/9_timestamp.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE IF EXISTS ONLY "workspaces" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 2 | ALTER TABLE IF EXISTS ONLY "workspaces" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 3 | 4 | ALTER TABLE IF EXISTS ONLY "sources" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 5 | ALTER TABLE IF EXISTS ONLY "sources" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 6 | 7 | ALTER TABLE IF EXISTS ONLY "plugins" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 8 | ALTER TABLE IF EXISTS ONLY "plugins" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 9 | 10 | ALTER TABLE IF EXISTS ONLY "events" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 11 | ALTER TABLE IF EXISTS ONLY "events" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 12 | 13 | ALTER TABLE IF EXISTS ONLY "endpoints" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 14 | ALTER TABLE IF EXISTS ONLY "endpoints" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 15 | 16 | ALTER TABLE IF EXISTS ONLY "attempts" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 17 | ALTER TABLE IF EXISTS ONLY "attempts" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 18 | 19 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 20 | ALTER TABLE IF EXISTS ONLY "attempt_details" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC'; 21 | -------------------------------------------------------------------------------- /pkg/reports/reports.go: -------------------------------------------------------------------------------- 1 | package reports 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/webhookx-io/webhookx/config" 12 | "github.com/webhookx-io/webhookx/pkg/license" 13 | "github.com/webhookx-io/webhookx/utils" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | var ( 18 | // URL is report url 19 | URL = "https://report.webhookx.io/report" 20 | 21 | uid = utils.UUIDShort() 22 | ) 23 | 24 | type data struct { 25 | UID string `json:"uid"` 26 | Version string `json:"version"` 27 | LicenseID string `json:"license_id"` 28 | LicensePlan string `json:"license_plan"` 29 | } 30 | 31 | func send(url string) error { 32 | lic := license.GetLicenser().License() 33 | data := data{ 34 | UID: uid, 35 | Version: config.VERSION, 36 | LicenseID: lic.ID, 37 | LicensePlan: lic.Plan, 38 | } 39 | 40 | buf := new(bytes.Buffer) 41 | err := json.NewEncoder(buf).Encode(data) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | client := &http.Client{ 47 | Timeout: time.Second * 15, 48 | Transport: &http.Transport{ 49 | DisableKeepAlives: true, 50 | TLSHandshakeTimeout: 10 * time.Second, 51 | ExpectContinueTimeout: 1 * time.Second, 52 | }, 53 | } 54 | resp, err := client.Post(url, "application/json", buf) 55 | if err != nil { 56 | return err 57 | } 58 | defer func() { _ = resp.Body.Close() }() 59 | _, _ = io.Copy(io.Discard, resp.Body) 60 | if resp.StatusCode != http.StatusOK { 61 | return fmt.Errorf("HTTP status %d", resp.StatusCode) 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func Report() { 68 | err := send(URL) 69 | if err != nil { 70 | zap.S().Debugf("failed to report anonymous data: %v", err) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /admin/api/middleware.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/webhookx-io/webhookx/db/entities" 9 | "github.com/webhookx-io/webhookx/pkg/contextx" 10 | "github.com/webhookx-io/webhookx/pkg/license" 11 | "github.com/webhookx-io/webhookx/pkg/types" 12 | "github.com/webhookx-io/webhookx/utils" 13 | ) 14 | 15 | func (api *API) contextMiddleware(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | var workspace *entities.Workspace 18 | var err error 19 | 20 | wid := mux.Vars(r)["workspace"] 21 | if wid == "" { 22 | wid = "default" 23 | } 24 | 25 | workspace, err = api.db.Workspaces.GetWorkspace(r.Context(), wid) 26 | api.assert(err) 27 | if workspace == nil { 28 | workspace, err = api.db.Workspaces.Get(r.Context(), wid) 29 | api.assert(err) 30 | } 31 | 32 | if workspace == nil { 33 | api.error(400, w, errors.New("invalid workspace: "+wid)) 34 | return 35 | } 36 | 37 | ctx := contextx.WithContext(r.Context(), &contextx.Context{ 38 | WorkspaceID: workspace.ID, 39 | WorkspaceName: utils.PointerValue(workspace.Name), 40 | }) 41 | r = r.WithContext(ctx) 42 | 43 | next.ServeHTTP(w, r) 44 | }) 45 | } 46 | 47 | func (api *API) licenseMiddleware(next http.Handler) http.Handler { 48 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | ctx, _ := contextx.FromContext(r.Context()) 50 | path, _ := mux.CurrentRoute(r).GetPathTemplate() 51 | method := r.Method 52 | if !license.GetLicenser().AllowAPI(ctx.WorkspaceName, path, method) { 53 | api.json(403, w, types.ErrorResponse{Message: MsgLicenseInvalid}) 54 | return 55 | } 56 | next.ServeHTTP(w, r) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /plugins/webhookx_signature/plugin.go: -------------------------------------------------------------------------------- 1 | package webhookx_signature 2 | 3 | import ( 4 | "context" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/getkin/kin-openapi/openapi3" 12 | "github.com/webhookx-io/webhookx/db/entities" 13 | "github.com/webhookx-io/webhookx/pkg/plugin" 14 | "github.com/webhookx-io/webhookx/utils" 15 | ) 16 | 17 | type Config struct { 18 | SigningSecret string `json:"signing_secret"` 19 | } 20 | 21 | func (c Config) Schema() *openapi3.Schema { 22 | return entities.LookupSchema("WebhookxSignaturePluginConfiguration") 23 | } 24 | 25 | type SignaturePlugin struct { 26 | plugin.BasePlugin[Config] 27 | 28 | ts time.Time // used in testing 29 | } 30 | 31 | func (p *SignaturePlugin) Name() string { 32 | return "webhookx-signature" 33 | } 34 | 35 | // TODO 36 | func (p *SignaturePlugin) ValidateConfig(config map[string]interface{}) error { 37 | if _, ok := config["signing_secret"]; !ok { 38 | config["signing_secret"] = utils.RandomString(32) 39 | } 40 | 41 | return p.BasePlugin.ValidateConfig(config) 42 | } 43 | 44 | func computeSignature(ts time.Time, payload []byte, secret string) []byte { 45 | mac := hmac.New(sha256.New, []byte(secret)) 46 | mac.Write([]byte(strconv.FormatInt(ts.Unix(), 10))) 47 | mac.Write([]byte(".")) 48 | mac.Write(payload) 49 | return mac.Sum(nil) 50 | } 51 | 52 | func (p *SignaturePlugin) ExecuteOutbound(ctx context.Context, outbound *plugin.Outbound) error { 53 | ts := p.ts 54 | if ts.IsZero() { 55 | ts = time.Now() 56 | } 57 | signature := computeSignature(ts, []byte(outbound.Payload), p.Config.SigningSecret) 58 | outbound.Headers["webhookx-signature"] = "v1=" + hex.EncodeToString(signature) 59 | outbound.Headers["webhookx-timestamp"] = strconv.FormatInt(ts.Unix(), 10) 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /test/fixtures/mtls/client.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC4uj/G56OuY8QA 3 | 8kvFIaLq5ACQp+NVa+wdgw0mP9U7r0y/CsjhsajfgLtn62DkQ+lCgM6D0HgpPuvX 4 | 9etTMV9RAKMQ1Gxpv9wP91Nfb3Nu02X1+nReX1LtV2Jheeu+NBQ4AeZifdhcPwvc 5 | zMaJGQ6i6/YVlKh7vWNlNhx/NcuCFhYEYSIKfceUUgd/6BpIHF8PyUKx4LtR0eYh 6 | hiUXh/MhYFzAa7xx+otT2RjjOPfQr8vb+nuqvxY8RbH+YuMwyRqAUNTI8fqt48d1 7 | 2aJi6+y9uPCpG109y4qjflAATWI9I/DQBXnpJN0FXqx0PZoLh3f1n53HboKjGh2a 8 | hl57OcvjAgMBAAECggEADEgvkKSfXAazXPa/I1u4hYA/vIMCz+s2qXkWNszA+1CN 9 | F9AMrm1pJcA4mixGQiAryQ4Wo9f2SLhUL84FLskxpfcpFzoCtCM//aEyJ2LjHIfj 10 | hV149fGhRabtERsRvkfES4S3rW4GttdnH2+9LPLJMNbjTA1P49v9fXQmW1KXiBcv 11 | nOVRz63Jd0YjICGZdOJJWEitKtRm9eM8jDRh3Ypk844Cgx76Iz3lZmc07okwi/sb 12 | j1YJm24oiERI3XEdB5Pq1tPkzePrIE1VZuMgP9twTZqR7CQjdXJl+Jm0Qpzjp9gm 13 | 36L1IYfwpEKA3yRQEmDCSh3icgL1jRuiIWtMRceexQKBgQD7VH70NYL3kogrCg1u 14 | fN3rNkb07aFK6JwBcHYQCyXBR2vfbyzJdu9f0m6rQ2u33GqvwCmaC3x2qlzZ5tZY 15 | 9X/QW10P3I/TJvlM32zKYF03M1GVL4gU4E94XLzYAdWV8LVIO/HHQirN9oafxMNv 16 | oKKDZwLzpUVymisZP7mtXyfOLwKBgQC8KPHAso3czG+1ivumiE/iTmF00aoHwyJr 17 | zUnbEMIDpHF/8N6OiWq0I5m+x2n2vGe/5/h9ywkIVPuS5RqcRyg66/mchgv0/xTQ 18 | XBrEynsgjsNSiViwc1TrJNq5IFRJw8vxT/CNmQLE33LIvD+7w+ZY211JALhD2GcY 19 | PBTCiZOEjQKBgDMP6LcvBAvOnpG3+iCfh+rY3TO379QrTD7SnXoG+cW6AAWmLcBE 20 | xL+AHnH3QbRaOOa6MPmWKdRmKnUu/A+Y2T34wgCN/D6XJYFjx1OannWvnHyl6ozr 21 | QdofZVKxlLZg8EPbwfSM0euEkbd2H4rXZQ0zaZsc0e5Fuknn845wzcKLAoGBAKpS 22 | deP0vS2tcUFoebuZkJZOVTGlyMAWB0aGIeDHHpildohVxWBJS+mcgEONx4Gtskyo 23 | 8usLqzV7l+60rI3ia6xKhz0EqjYv4OtrNGAG2cXy9SP1Z+7xt2DTj5ocha/wKOBb 24 | eGj0pOkJS6IhpZ+WCSFOEPdQS3w+m7P4TuJ6HqrRAoGBALD36isPiolbhF3H9A0Z 25 | CaymMne7tWexEjRj5jAgyl527soZ85Q+tZq5gc/wdR+RtJpM2Mic82aPLDlW53cn 26 | iYTfNsMa9OM+pV+GnXVT1ynYrMYH205Wewgwf3Rve+8XCCLc6RaPHuAyTkCNHwo6 27 | 5RUANpigw2zontXsIR8k/AA8 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /test/fixtures/mtls/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCXEusSSsWTzO+u 3 | sBn+crTqTk13wMuRxIKfFPYBevV0gDJwyVfvQs9K+Vihkd2EW08l53+bbn3mKAbB 4 | TG7EmMFSSJmSiUp6PvW5Z1On7Wu468DYKKzdvwXI8a4HB1TIE5cN7sj+XPgPairf 5 | io6x7CaA/V6MOWXMT6s2NKQfkr9VF2ls/SMrfONbFY77eFxAR8DFFqC40OQ5L7C3 6 | THnf6zdcZwb+Bs3zpvtKyN2+IEB/t+7smgkBSlzwteCHQI4gL1SOIWEpLfcLB4c5 7 | t3WwZQVfyX61nD2KypD8k6bmgemDgZKazU5EIwdnEseMU1oT7zWLYg8mCoT+6OOs 8 | QjTGmZQZAgMBAAECggEALQnUzuU/tep44ilZ9oOX7+pcKgFuLwzYrDiBhrtzhcHa 9 | R8mez5OpXP6tL63ezmCyXeiAIIR2QDFaojH5K98mczN1pTwM2hj/BMELLZsYbE7M 10 | dSTbNFiIjvmOGkZTPjqo49x2S27H/UB3e6FBHUX7zKS8lS1fbeOqdUSIUWlcZS9a 11 | Ikci5ONdXNVz/QbEzAMhXQ2dcVwNZdz4y2OlbjN6/lP63LikivWOjgTy5ev0UATX 12 | EU6QaEeh0jTpTom1PvDSW/8EYxCZqodVdfKpTznf1g0h5uT8EGkGxOs2ap0/rZP2 13 | ebKNP217llKcas95KJ2wl0nKtwaQ5uWoq3cdS58SCQKBgQDRE5BF8wGhPWF2K7df 14 | OkFsZeW05E8lanz4fO21skwnf6J9wINz8vkgeyGUH0w+fiFUIKv+F8AZS+cj1itj 15 | yjgVPKLj17K8Hhv03boYgmlIXrawIUTscqGILFWV2CMX6brjiqGQHQBs1Q1WYmC4 16 | 1Bdyy938TnXaP8KXv2CWyX0LwwKBgQC4+tVhxFYEZlw4ObflvjJjkYuBr4mmQQ2r 17 | dhYdR0Oxyaa5my1vE7Pw1EWpIKNk07AJK+zDJqaDJDVWXfjkhCo51Kj2nyrSllMb 18 | 8mFs+6CbwCZvdCY4uzjLakBho8wyLsjORzb/qp+/SWCgf4/jeBg1xtkwQo9z/Asg 19 | l9kJvEZO8wKBgAdroFU7OLWWTh05k/qHQMcuHqb662wyiVjwZidqupU0THoWGRRG 20 | bV0fwaNWMQiOxXQM7M3J3gGH1h5JfaS/CpqGWmmnwCo5D1jzfaVdC4uMAQPjSmTx 21 | 9JW2rRryXtx8aSumQfGxddBnB2AngbNNo79pSOmphzlFxgxIuI7he9StAoGAF6zj 22 | CrRaXg3L19ZrVxhU0rGaLWsOLx08ZqmigvTQET1B/ZeC5Sica0J/9/mZcBo3+bSJ 23 | hSC5RyenO/qjFHxl+yjgx0/v5yweTwFivtQl5kldof43tiMgTci3nMeeJv4d7Wjn 24 | /SkVcSIvH9uzyuVgE+Hzgl3ChpHHytAkkz5psUkCgYADfEgFyntUi93St4IDDIMr 25 | BifnnKLZ9epPMhb+b8QcsqvrKDQyngMhF+mhMNomnpku6SUTwSrkMW9r5J4rhX/k 26 | lMu2mIUVzaO9lmakVGkbNz4VDn7/DoMP1ihraIDkR4+fg0l41Y6w65XCwGoMUXiU 27 | bDMj+j/9bfHl2zwo4Sxbag== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /admin/admin.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/webhookx-io/webhookx/config/modules" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // Admin is an HTTP Server 15 | type Admin struct { 16 | cfg *modules.AdminConfig 17 | s *http.Server 18 | log *zap.SugaredLogger 19 | } 20 | 21 | func NewAdmin(cfg modules.AdminConfig, handler http.Handler) *Admin { 22 | s := &http.Server{ 23 | Handler: handler, 24 | Addr: cfg.Listen, 25 | 26 | WriteTimeout: 60 * time.Second, 27 | ReadTimeout: 60 * time.Second, 28 | } 29 | 30 | admin := &Admin{ 31 | cfg: &cfg, 32 | s: s, 33 | log: zap.S().Named("admin"), 34 | } 35 | 36 | return admin 37 | } 38 | 39 | // Start starts an HTTP server 40 | func (a *Admin) Start() { 41 | go func() { 42 | tls := a.cfg.TLS 43 | if tls.Enabled() { 44 | if err := a.s.ListenAndServeTLS(tls.Cert, tls.Key); err != nil && err != http.ErrServerClosed { 45 | zap.S().Errorf("Failed to start admin HTTPS server: %v", err) 46 | os.Exit(1) 47 | } 48 | } else { 49 | if err := a.s.ListenAndServe(); err != nil && err != http.ErrServerClosed { 50 | zap.S().Errorf("Failed to start admin HTTP server: %v", err) 51 | os.Exit(1) 52 | } 53 | } 54 | }() 55 | 56 | a.log.Infow(fmt.Sprintf(`listening on address "%s"`, a.cfg.Listen), 57 | "tls", a.cfg.TLS.Enabled()) 58 | 59 | if a.cfg.DebugEndpoints { 60 | a.log.Infow("serving debug endpoints at /debug", "pprof", "/debug/pprof/") 61 | } 62 | } 63 | 64 | // Stop stops the HTTP server 65 | func (a *Admin) Stop() error { 66 | // TODO shutdown timeout 67 | if err := a.s.Shutdown(context.TODO()); err != nil { 68 | // Error from closing listeners, or context timeout: 69 | return err 70 | } 71 | a.log.Infof("admin stopped") 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /db/entities/endpoint.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | type Endpoint struct { 9 | ID string `json:"id" db:"id"` 10 | Name *string `json:"name" db:"name"` 11 | Description *string `json:"description" db:"description"` 12 | Enabled bool `json:"enabled" db:"enabled"` 13 | Request RequestConfig `json:"request" db:"request"` 14 | Retry Retry `json:"retry" db:"retry"` 15 | Events Strings `json:"events" db:"events"` 16 | Metadata Metadata `json:"metadata" db:"metadata"` 17 | RateLimit *RateLimit `json:"rate_limit" yaml:"rate_limit" db:"rate_limit"` 18 | 19 | BaseModel `yaml:"-"` 20 | } 21 | 22 | func (m *Endpoint) SchemaName() string { 23 | return "Endpoint" 24 | } 25 | 26 | type RequestConfig struct { 27 | URL string `json:"url"` 28 | Method string `json:"method"` 29 | Headers Headers `json:"headers"` 30 | Timeout int64 `json:"timeout"` 31 | } 32 | 33 | func (m *RequestConfig) Scan(src interface{}) error { 34 | return json.Unmarshal(src.([]byte), m) 35 | } 36 | 37 | func (m RequestConfig) Value() (driver.Value, error) { 38 | return json.Marshal(m) 39 | } 40 | 41 | type RetryStrategy string 42 | 43 | const ( 44 | RetryStrategyFixed RetryStrategy = "fixed" 45 | ) 46 | 47 | func (m RetryStrategy) String() string { 48 | return string(m) 49 | } 50 | 51 | type Retry struct { 52 | Strategy RetryStrategy `json:"strategy"` 53 | Config FixedStrategyConfig `json:"config"` 54 | } 55 | 56 | func (m *Retry) Scan(src interface{}) error { 57 | return json.Unmarshal(src.([]byte), m) 58 | } 59 | 60 | func (m Retry) Value() (driver.Value, error) { 61 | return json.Marshal(m) 62 | } 63 | 64 | type FixedStrategyConfig struct { 65 | Attempts []int64 `json:"attempts"` 66 | } 67 | -------------------------------------------------------------------------------- /test/tracing/ginkgo_test.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/webhookx-io/webhookx/app" 11 | "github.com/webhookx-io/webhookx/db/entities" 12 | "github.com/webhookx-io/webhookx/pkg/tracing" 13 | "github.com/webhookx-io/webhookx/test/helper" 14 | "github.com/webhookx-io/webhookx/test/helper/factory" 15 | "github.com/webhookx-io/webhookx/utils" 16 | "go.opentelemetry.io/otel/trace" 17 | ) 18 | 19 | var _ = Describe("tracing disabled", Ordered, func() { 20 | for _, protocol := range []string{"grpc", "http/protobuf"} { 21 | Context(protocol, func() { 22 | var app *app.Application 23 | 24 | entitiesConfig := helper.EntitiesConfig{ 25 | Endpoints: []*entities.Endpoint{factory.EndpointP()}, 26 | Sources: []*entities.Source{factory.SourceP()}, 27 | } 28 | 29 | BeforeAll(func() { 30 | helper.InitOtelOutput() 31 | helper.InitDB(true, &entitiesConfig) 32 | 33 | envs := map[string]string{ 34 | "WEBHOOKX_TRACING_ENABLED": "false", 35 | "WEBHOOKX_TRACING_SAMPLING_RATE": "1.0", 36 | } 37 | app = utils.Must(helper.Start(envs)) 38 | }) 39 | 40 | AfterAll(func() { 41 | app.Stop() 42 | }) 43 | 44 | It("disabled tracing "+protocol, func() { 45 | ctx := context.Background() 46 | tCtx, span := tracing.Start(ctx, "test") 47 | spanCtxValid := span.SpanContext().IsValid() 48 | assert.False(GinkgoT(), spanCtxValid, "span context should be invalid") 49 | valid := trace.SpanContextFromContext(tCtx).IsValid() 50 | assert.False(GinkgoT(), valid, "span context should be invalid") 51 | }) 52 | }) 53 | } 54 | }) 55 | 56 | func TestTracing(t *testing.T) { 57 | RegisterFailHandler(Fail) 58 | RunSpecs(t, "Tracing Suite") 59 | } 60 | -------------------------------------------------------------------------------- /plugins/jsonschema_validator/jsonschema/jsonschema_test.go: -------------------------------------------------------------------------------- 1 | package jsonschema 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | func TestJSONSchema(t *testing.T) { 12 | RegisterFailHandler(Fail) 13 | RunSpecs(t, "Schema Validator Suite") 14 | } 15 | 16 | var _ = Describe("Schema Validator Plugin", func() { 17 | 18 | Context("JSONSchema Validator", func() { 19 | It("should validate valid JSON data against the schema", func() { 20 | schemaDef := `{ 21 | "type": "object", 22 | "properties": { 23 | "name": { "type": "string" }, 24 | "age": { "type": "integer", "minimum": 0 } 25 | }, 26 | "required": ["name", "age"] 27 | }` 28 | 29 | validator := New([]byte(schemaDef)) 30 | 31 | validData := map[string]interface{}{"name": "John Doe", "age": 30} 32 | ctx := &ValidatorContext{ 33 | HTTPRequest: &HTTPRequest{ 34 | Data: validData, 35 | }, 36 | } 37 | 38 | err := validator.Validate(ctx) 39 | Expect(err).To(BeNil()) 40 | }) 41 | 42 | It("should return an error for invalid JSON data against the schema", func() { 43 | schemaDef := `{ 44 | "type": "object", 45 | "properties": { 46 | "name": { "type": "string" }, 47 | "age": { "type": "integer", "minimum": 0 } 48 | }, 49 | "required": ["name", "age"] 50 | }` 51 | 52 | validator := New([]byte(schemaDef)) 53 | 54 | invalidData := map[string]interface{}{"name": "John Doe", "age": -5} 55 | ctx := &ValidatorContext{ 56 | HTTPRequest: &HTTPRequest{ 57 | Data: invalidData, 58 | }, 59 | } 60 | 61 | err := validator.Validate(ctx) 62 | Expect(err).ToNot(BeNil()) 63 | b, _ := json.Marshal(err) 64 | Expect(string(b)).To(Equal(`{"message":"request validation","fields":{"age":"number must be at least 0"}}`)) 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/admin/listen_test.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "github.com/go-resty/resty/v2" 5 | . "github.com/onsi/ginkgo/v2" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/webhookx-io/webhookx/app" 8 | "github.com/webhookx-io/webhookx/test" 9 | "github.com/webhookx-io/webhookx/test/helper" 10 | "github.com/webhookx-io/webhookx/utils" 11 | ) 12 | 13 | var _ = Describe("admin", Ordered, func() { 14 | 15 | Context("listen", func() { 16 | var app *app.Application 17 | var adminClient *resty.Client 18 | 19 | BeforeAll(func() { 20 | helper.InitDB(true, nil) 21 | app = utils.Must(helper.Start(map[string]string{})) 22 | adminClient = helper.AdminClient() 23 | }) 24 | 25 | AfterAll(func() { 26 | app.Stop() 27 | }) 28 | 29 | It("admin listen", func() { 30 | resp, err := adminClient.R().Get("/") 31 | assert.Nil(GinkgoT(), err) 32 | assert.Equal(GinkgoT(), 200, resp.StatusCode()) 33 | }) 34 | 35 | It("404", func() { 36 | resp, err := adminClient.R().Get("/notfound") 37 | assert.Nil(GinkgoT(), err) 38 | assert.Equal(GinkgoT(), 404, resp.StatusCode()) 39 | assert.Equal(GinkgoT(), `{"message":"not found"}`, string(resp.Body())) 40 | }) 41 | }) 42 | 43 | Context("tls listen", func() { 44 | var app *app.Application 45 | var adminClient *resty.Client 46 | 47 | BeforeAll(func() { 48 | helper.InitDB(true, nil) 49 | app = utils.Must(helper.Start(map[string]string{ 50 | "WEBHOOKX_ADMIN_TLS_CERT": test.FilePath("server.crt"), 51 | "WEBHOOKX_ADMIN_TLS_KEY": test.FilePath("server.key"), 52 | })) 53 | adminClient = helper.AdminTLSClient() 54 | }) 55 | 56 | AfterAll(func() { 57 | app.Stop() 58 | }) 59 | 60 | It("admin tls listen", func() { 61 | resp, err := adminClient.R().Get("/") 62 | assert.Nil(GinkgoT(), err) 63 | assert.Equal(GinkgoT(), 200, resp.StatusCode()) 64 | }) 65 | }) 66 | 67 | }) 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: run GoReleaser 23 | uses: goreleaser/goreleaser-action@v6 24 | with: 25 | distribution: goreleaser 26 | version: "~> v2" 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} 31 | 32 | docker: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Set up QEMU 38 | uses: docker/setup-qemu-action@v3 39 | 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v3 42 | 43 | - name: Docker meta 44 | id: meta 45 | uses: docker/metadata-action@v5 46 | with: 47 | images: webhookx/webhookx 48 | tags: | 49 | type=ref,event=branch 50 | type=ref,event=pr 51 | type=semver,pattern={{version}} 52 | type=semver,pattern={{major}}.{{minor}} 53 | 54 | - name: Login to DockerHub 55 | uses: docker/login-action@v3 56 | with: 57 | username: ${{ secrets.DOCKERHUB_USERNAME }} 58 | password: ${{ secrets.DOCKERHUB_TOKEN }} 59 | 60 | - name: Build and push 61 | uses: docker/build-push-action@v6 62 | with: 63 | push: true 64 | context: . 65 | tags: ${{ steps.meta.outputs.tags }} 66 | labels: ${{ steps.meta.outputs.labels }} 67 | platforms: linux/amd64,linux/arm64 68 | -------------------------------------------------------------------------------- /status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/webhookx-io/webhookx/config" 11 | "github.com/webhookx-io/webhookx/config/modules" 12 | "github.com/webhookx-io/webhookx/pkg/accesslog" 13 | "github.com/webhookx-io/webhookx/pkg/tracing" 14 | "github.com/webhookx-io/webhookx/status/health" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | type Status struct { 19 | api *API 20 | cfg *modules.StatusConfig 21 | s *http.Server 22 | log *zap.SugaredLogger 23 | } 24 | 25 | type Options struct { 26 | AccessLog accesslog.AccessLogger 27 | Config *config.Config 28 | Indicators []*health.Indicator 29 | } 30 | 31 | func NewStatus(cfg modules.StatusConfig, tracer *tracing.Tracer, opts Options) *Status { 32 | api := &API{ 33 | debugEndpoints: cfg.DebugEndpoints, 34 | tracer: tracer, 35 | accessLogger: opts.AccessLog, 36 | indicators: opts.Indicators, 37 | } 38 | s := &http.Server{ 39 | Handler: api.Handler(), 40 | Addr: cfg.Listen, 41 | WriteTimeout: 10 * time.Second, 42 | ReadTimeout: 10 * time.Second, 43 | } 44 | 45 | status := &Status{ 46 | api: api, 47 | cfg: &cfg, 48 | s: s, 49 | log: zap.S().Named("status"), 50 | } 51 | 52 | return status 53 | } 54 | 55 | func (a *Status) Start() { 56 | go func() { 57 | if err := a.s.ListenAndServe(); err != nil && err != http.ErrServerClosed { 58 | zap.S().Errorf("Failed to start status HTTP server: %v", err) 59 | os.Exit(1) 60 | } 61 | }() 62 | 63 | a.log.Infow(fmt.Sprintf(`listening on address "%s"`, a.cfg.Listen)) 64 | 65 | if a.cfg.DebugEndpoints { 66 | a.log.Infow("serving debug endpoints at /debug", "pprof", "/debug/pprof/") 67 | } 68 | } 69 | 70 | func (a *Status) Stop() error { 71 | if err := a.s.Shutdown(context.TODO()); err != nil { 72 | return err 73 | } 74 | a.log.Infof("status stopped") 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /status/types.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | type HealthResponse struct { 4 | Status string `json:"status"` 5 | Components map[string]HealthResult `json:"components"` 6 | } 7 | 8 | type HealthResult struct { 9 | Status string `json:"status"` 10 | Error *string `json:"error,omitempty"` 11 | } 12 | 13 | type StatusResponse struct { 14 | UpTime string `json:"uptime"` 15 | Runtime RuntimeStats `json:"runtime"` 16 | Memory MemoryStats `json:"memory"` 17 | Database DatabaseStats `json:"database"` 18 | InboundRequests int64 `json:"inbound_requests"` 19 | InboundFailedRequests int64 `json:"inbound_failed_requests"` 20 | OutboundRequests int64 `json:"outbound_requests"` 21 | OutboundProcessingRequests int64 `json:"outbound_processing_requests"` 22 | OutboundFailedRequests int64 `json:"outbound_failed_requests"` 23 | Queue QueueStats `json:"queue"` 24 | Event EventStats `json:"event"` 25 | } 26 | 27 | type MemoryStats struct { 28 | Alloc string `json:"alloc"` 29 | Sys string `json:"sys"` 30 | HeapAlloc string `json:"heap_alloc"` 31 | HeapIdle string `json:"heap_idle"` 32 | HeapInuse string `json:"heap_inuse"` 33 | HeapObjects int64 `json:"heap_objects"` 34 | GC int64 `json:"gc"` 35 | } 36 | 37 | type DatabaseStats struct { 38 | TotalConnections int `json:"total_connections"` 39 | ActiveConnections int `json:"active_connections"` 40 | } 41 | 42 | type RuntimeStats struct { 43 | Go string `json:"go"` 44 | Goroutines int `json:"goroutines"` 45 | } 46 | 47 | type QueueStats struct { 48 | Size int64 `json:"size"` 49 | BacklogLatency int64 `json:"backlog_latency_secs"` 50 | } 51 | 52 | type EventStats struct { 53 | Pending int64 `json:"pending"` 54 | } 55 | -------------------------------------------------------------------------------- /test/fixtures/webhookx.yml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | - name: default-endpoint 3 | request: 4 | timeout: 10000 5 | url: https://httpbin.org/anything 6 | method: POST 7 | headers: 8 | x-apikey: secret 9 | retry: 10 | strategy: fixed 11 | config: 12 | attempts: [0, 3600, 3600] 13 | events: [ "charge.succeeded" ] 14 | metadata: 15 | key1: value1 16 | key2: value2 17 | plugins: 18 | - name: webhookx-signature 19 | config: 20 | signing_secret: foo 21 | - name: wasm 22 | enabled: false 23 | config: 24 | file: test/fixtures/noop.wasm 25 | envs: 26 | foo: bar 27 | 28 | sources: 29 | - name: default-source 30 | type: http 31 | config: 32 | http: 33 | path: / 34 | methods: [ "POST" ] 35 | response: 36 | code: 200 37 | content_type: application/json 38 | body: '{"message": "OK"}' 39 | plugins: 40 | - name: function 41 | config: 42 | function: "function handle() {}" 43 | - name: "jsonschema-validator" 44 | config: 45 | draft: "6" 46 | default_schema: | 47 | { 48 | "type": "object", 49 | "properties": { 50 | "id": { "type": "string" } 51 | }, 52 | "required": ["id"] 53 | } 54 | schemas: 55 | charge.succeeded: 56 | schema: | 57 | { 58 | "type": "object", 59 | "properties": { 60 | "id": { "type": "string" }, 61 | "amount": { "type": "integer", "minimum": 1 }, 62 | "currency": { "type": "string", "minLength": 3, "maxLength": 6 } 63 | }, 64 | "required": ["id", "amount", "currency"] 65 | } 66 | -------------------------------------------------------------------------------- /pkg/accesslog/entry.go: -------------------------------------------------------------------------------- 1 | package accesslog 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/rs/zerolog" 9 | "github.com/webhookx-io/webhookx/utils" 10 | ) 11 | 12 | type Entry struct { 13 | Username string `json:"username"` 14 | Latency time.Duration `json:"latency"` 15 | ClientIP string `json:"client_ip"` 16 | 17 | Request Request `json:"request"` 18 | Response Response `json:"response"` 19 | } 20 | 21 | type Request struct { 22 | Method string `json:"method"` 23 | Path string `json:"path"` 24 | Proto string `json:"proto"` 25 | Headers map[string]string `json:"headers"` 26 | } 27 | 28 | type Response struct { 29 | Status int `json:"status"` 30 | Size int `json:"size"` 31 | } 32 | 33 | func NewEntry(r *http.Request) *Entry { 34 | host, _ := parseHostPort(r.RemoteAddr) 35 | username, _, _ := r.BasicAuth() 36 | entry := Entry{ 37 | Username: username, 38 | ClientIP: host, 39 | Request: Request{ 40 | Method: r.Method, 41 | Path: r.URL.Path, 42 | Proto: r.Proto, 43 | Headers: map[string]string{ 44 | "user-agent": r.UserAgent(), 45 | "referer": r.Referer(), 46 | }, 47 | }, 48 | } 49 | return &entry 50 | } 51 | 52 | func (m *Entry) MarshalZerologObject(e *zerolog.Event) { 53 | e.Str("client_ip", m.ClientIP) 54 | e.Str("username", m.Username) 55 | e.Any("request", m.Request) 56 | e.Any("response", m.Response) 57 | e.Int64("latency", m.Latency.Milliseconds()) 58 | } 59 | 60 | func (m *Entry) String() string { 61 | return fmt.Sprintf(`%s - %s "%s %s %s" %d %d %dms "%s" "%s"`, 62 | m.ClientIP, 63 | utils.DefaultIfZero(m.Username, "-"), 64 | m.Request.Method, 65 | m.Request.Path, 66 | m.Request.Proto, 67 | m.Response.Status, 68 | m.Response.Size, 69 | m.Latency.Milliseconds(), 70 | utils.DefaultIfZero(m.Request.Headers["referer"], "-"), 71 | utils.DefaultIfZero(m.Request.Headers["user-agent"], "-")) 72 | } 73 | -------------------------------------------------------------------------------- /pkg/secret/reference/reference.go: -------------------------------------------------------------------------------- 1 | package reference 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | ErrReferenceInvalid = errors.New("invalid reference") 12 | ) 13 | 14 | // Reference represents the definition of a reference. 15 | // Syntax: {secret:///[.][?]} 16 | type Reference struct { 17 | Reference string 18 | Provider string 19 | Name string 20 | JsonPointer string 21 | Properties map[string]string 22 | } 23 | 24 | func (r *Reference) String() string { 25 | return r.Reference 26 | } 27 | 28 | func Parse(reference string) (*Reference, error) { 29 | s := strings.TrimPrefix(reference, "{") 30 | s = strings.TrimSuffix(s, "}") 31 | u, err := url.Parse(s) 32 | if err != nil { 33 | return nil, fmt.Errorf("%w: %s", ErrReferenceInvalid, err) 34 | } 35 | if u.Scheme != "secret" { 36 | return nil, fmt.Errorf("%w: %q", ErrReferenceInvalid, "invalid reference scheme") 37 | } 38 | if u.Host == "" { 39 | return nil, fmt.Errorf("%w: %q", ErrReferenceInvalid, "invalid reference provider") 40 | } 41 | if u.Path == "" || u.Path == "/" { 42 | return nil, fmt.Errorf("%w: %q", ErrReferenceInvalid, "invalid reference name") 43 | } 44 | values, err := url.ParseQuery(u.RawQuery) 45 | if err != nil { 46 | return nil, fmt.Errorf("%w: %q", ErrReferenceInvalid, "invalid reference properties") 47 | } 48 | 49 | ref := &Reference{ 50 | Reference: reference, 51 | Name: strings.TrimPrefix(u.Path, "/"), 52 | Provider: u.Host, 53 | Properties: make(map[string]string), 54 | } 55 | for k := range values { 56 | ref.Properties[k] = values.Get(k) 57 | } 58 | if parts := strings.SplitN(ref.Name, ".", 2); len(parts) == 2 { 59 | ref.Name = parts[0] 60 | ref.JsonPointer = parts[1] 61 | } 62 | return ref, err 63 | } 64 | 65 | func IsReference(s string) bool { 66 | return strings.HasPrefix(s, "{secret://") && strings.HasSuffix(s, "}") 67 | } 68 | -------------------------------------------------------------------------------- /pkg/log/zap.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/webhookx-io/webhookx/config/modules" 8 | "github.com/webhookx-io/webhookx/utils" 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/zapcore" 11 | ) 12 | 13 | func NewZapLogger(cfg *modules.LogConfig) (*zap.SugaredLogger, error) { 14 | level, err := zapcore.ParseLevel(string(cfg.Level)) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | encodingMap := map[string]string{ 20 | "text": "console", 21 | "json": "json", 22 | } 23 | encoderMap := map[string]zapcore.EncoderConfig{ 24 | "text": zap.NewDevelopmentEncoderConfig(), 25 | "json": zap.NewProductionEncoderConfig(), 26 | } 27 | zapConfig := zap.Config{ 28 | Level: zap.NewAtomicLevelAt(level), 29 | Development: false, 30 | DisableCaller: true, 31 | DisableStacktrace: true, 32 | Encoding: encodingMap[string(cfg.Format)], 33 | EncoderConfig: encoderMap[string(cfg.Format)], 34 | } 35 | zapConfig.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { 36 | enc.AppendString(t.Format("2006/01/02 15:04:05.000")) 37 | } 38 | if cfg.Format == modules.LogFormatText { 39 | zapConfig.EncoderConfig.EncodeName = func(loggerName string, enc zapcore.PrimitiveArrayEncoder) { 40 | enc.AppendString(fmt.Sprintf("%-8s", "["+loggerName+"]")) 41 | } 42 | if cfg.Colored { 43 | zapConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 44 | } 45 | zapConfig.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { 46 | enc.AppendString(utils.Colorize(t.Format("2006/01/02 15:04:05.000"), utils.ColorDarkGray, cfg.Colored)) 47 | } 48 | } 49 | 50 | if cfg.File == "" { 51 | zapConfig.OutputPaths = []string{"/dev/stdout"} 52 | } else { 53 | zapConfig.OutputPaths = []string{cfg.File} 54 | } 55 | 56 | logger, err := zapConfig.Build() 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return logger.Sugar(), nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/schedule/scheduler.go: -------------------------------------------------------------------------------- 1 | package schedule 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "time" 7 | 8 | "github.com/robfig/cron/v3" 9 | ) 10 | 11 | type Task struct { 12 | id cron.EntryID 13 | 14 | Name string 15 | InitialDelay time.Duration 16 | Interval time.Duration 17 | Do func() 18 | } 19 | 20 | type Scheduler interface { 21 | AddTask(task *Task) 22 | GetTask(name string) *Task 23 | Start() 24 | Stop() 25 | } 26 | 27 | var ( 28 | ErrTaskAdded = errors.New("task already added") 29 | ) 30 | 31 | var _ Scheduler = &DefaultScheduler{} 32 | 33 | type IntervalSchedule struct { 34 | once sync.Once 35 | InitialDelay time.Duration 36 | Interval time.Duration 37 | } 38 | 39 | func (s *IntervalSchedule) Next(t time.Time) time.Time { 40 | interval := s.Interval 41 | s.once.Do(func() { 42 | interval = s.InitialDelay 43 | }) 44 | return t.Add(interval) 45 | } 46 | 47 | type DefaultScheduler struct { 48 | cron *cron.Cron 49 | tasks map[string]*Task 50 | mux sync.RWMutex 51 | } 52 | 53 | func NewScheduler() Scheduler { 54 | return &DefaultScheduler{ 55 | cron: cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger))), 56 | tasks: make(map[string]*Task), 57 | } 58 | } 59 | 60 | func (s *DefaultScheduler) AddTask(task *Task) { 61 | s.mux.Lock() 62 | defer s.mux.Unlock() 63 | 64 | if _, exists := s.tasks[task.Name]; exists { 65 | panic(ErrTaskAdded) 66 | } 67 | 68 | schedule := &IntervalSchedule{ 69 | InitialDelay: task.InitialDelay, 70 | Interval: task.Interval, 71 | } 72 | entryID := s.cron.Schedule(schedule, cron.FuncJob(task.Do)) 73 | 74 | task.id = entryID 75 | s.tasks[task.Name] = task 76 | } 77 | 78 | func (s *DefaultScheduler) GetTask(id string) *Task { 79 | s.mux.RLock() 80 | defer s.mux.RUnlock() 81 | return s.tasks[id] 82 | } 83 | 84 | func (s *DefaultScheduler) Start() { 85 | s.cron.Start() 86 | } 87 | 88 | func (s *DefaultScheduler) Stop() { 89 | ctx := s.cron.Stop() 90 | <-ctx.Done() 91 | } 92 | -------------------------------------------------------------------------------- /plugins/wasm/function_request.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/tetratelabs/wazero/api" 8 | "github.com/webhookx-io/webhookx/pkg/plugin" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func GetRequestJSON(ctx context.Context, m api.Module, jsonPtr, jsonSizePtr uint32) Status { 13 | value, ok := fromContext(ctx) 14 | if !ok { 15 | zap.S().Error("[wasm] invalid context") 16 | return StatusInternalFailure 17 | } 18 | 19 | bytes, err := json.Marshal(value) 20 | if err != nil { 21 | zap.S().Errorf("[wasm] failed to marshal value: %v", err) 22 | return StatusInternalFailure 23 | } 24 | 25 | allocate := m.ExportedFunction("allocate") 26 | if allocate == nil { 27 | zap.S().Error("[wasm] exported function 'allocate' is not defined") 28 | return StatusInternalFailure 29 | } 30 | 31 | str := string(bytes) 32 | ptr, err := writeString(ctx, m.Memory(), allocate, str) 33 | if err != nil { 34 | return StatusInvalidMemoryAccess 35 | } 36 | if ptr == 0 { 37 | zap.S().Error("[wasm] exported function 'allocate' returned 0") 38 | return StatusInvalidMemoryAccess 39 | } 40 | 41 | if !m.Memory().WriteUint32Le(jsonPtr, ptr) { 42 | return StatusInvalidMemoryAccess 43 | } 44 | if !m.Memory().WriteUint32Le(jsonSizePtr, uint32(len(str))) { 45 | return StatusInvalidMemoryAccess 46 | } 47 | 48 | return StatusOk 49 | } 50 | 51 | func SetRequestJSON(ctx context.Context, m api.Module, jsonPtr, jsonSize uint32) Status { 52 | str, ok := readString(m.Memory(), jsonPtr, jsonSize) 53 | if !ok { 54 | return StatusInvalidMemoryAccess 55 | } 56 | 57 | var value plugin.Outbound 58 | if err := json.Unmarshal([]byte(str), &value); err != nil { 59 | return StatusInvalidJSON 60 | } 61 | 62 | req, ok := fromContext(ctx) 63 | if !ok { 64 | return StatusInternalFailure 65 | } 66 | 67 | req.URL = value.URL 68 | req.Headers = value.Headers 69 | req.Method = value.Method 70 | req.Payload = value.Payload 71 | 72 | return StatusOk 73 | } 74 | -------------------------------------------------------------------------------- /db/dao/attempt_detail_dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/webhookx-io/webhookx/eventbus" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/webhookx-io/webhookx/constants" 12 | "github.com/webhookx-io/webhookx/db/entities" 13 | "github.com/webhookx-io/webhookx/pkg/tracing" 14 | "go.opentelemetry.io/otel/trace" 15 | ) 16 | 17 | type attemptDetailDao struct { 18 | *DAO[entities.AttemptDetail] 19 | } 20 | 21 | func NewAttemptDetailDao(db *sqlx.DB, bus *eventbus.EventBus, workspace bool) AttemptDetailDAO { 22 | opts := Options{ 23 | Table: "attempt_details", 24 | EntityName: "attempt_detail", 25 | Workspace: workspace, 26 | CachePropagate: false, 27 | CacheName: constants.AttemptDetailCacheKey.Name, 28 | } 29 | return &attemptDetailDao{ 30 | DAO: NewDAO[entities.AttemptDetail](db, bus, opts), 31 | } 32 | } 33 | 34 | func (dao *attemptDetailDao) Insert(ctx context.Context, attemptDetail *entities.AttemptDetail) error { 35 | ctx, span := tracing.Start(ctx, fmt.Sprintf("dao.%s.insert", dao.opts.Table), trace.WithSpanKind(trace.SpanKindServer)) 36 | defer span.End() 37 | 38 | now := time.Now() 39 | values := []interface{}{attemptDetail.ID, attemptDetail.RequestHeaders, attemptDetail.RequestBody, attemptDetail.ResponseHeaders, attemptDetail.ResponseBody, now, now, attemptDetail.WorkspaceId} 40 | 41 | sql := `INSERT INTO attempt_details (id, request_headers, request_body, response_headers, response_body, created_at, updated_at, ws_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 42 | ON CONFLICT (id) DO UPDATE SET 43 | request_headers = EXCLUDED.request_headers, 44 | request_body = EXCLUDED.request_body, 45 | response_headers = EXCLUDED.response_headers, 46 | response_body = EXCLUDED.response_body, 47 | updated_at = EXCLUDED.updated_at` 48 | 49 | result, err := dao.DB(ctx).ExecContext(ctx, sql, values...) 50 | if err != nil { 51 | return err 52 | } 53 | _, err = result.RowsAffected() 54 | return err 55 | } 56 | -------------------------------------------------------------------------------- /db/errs/error.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/jackc/pgerrcode" 10 | "github.com/jackc/pgx/v5/pgconn" 11 | ) 12 | 13 | type DBError struct { 14 | Err error 15 | } 16 | 17 | func (e *DBError) Error() string { 18 | return e.Err.Error() 19 | } 20 | 21 | func NewDBError(err error) *DBError { 22 | return &DBError{Err: err} 23 | } 24 | 25 | func parsePgError(detail string) (props []string, table string) { 26 | re := regexp.MustCompile(`Key \(([^)]+)\)=\(([^)]+)\)`) 27 | m := re.FindStringSubmatch(detail) 28 | if len(m) == 3 { 29 | fields := strings.Split(m[1], ", ") 30 | values := strings.Split(m[2], ", ") 31 | for i, field := range fields { 32 | v := "" 33 | if i < len(values) { 34 | v = values[i] 35 | } 36 | props = append(props, fmt.Sprintf("%s='%s'", field, v)) 37 | } 38 | } 39 | 40 | re = regexp.MustCompile(`table "([^"]+)"`) 41 | m = re.FindStringSubmatch(detail) 42 | if len(m) == 2 { 43 | table = m[1] 44 | } 45 | 46 | return 47 | } 48 | 49 | func ConvertError(err error) error { 50 | var pgErr *pgconn.PgError 51 | if errors.As(err, &pgErr) { 52 | switch pgErr.Code { 53 | case pgerrcode.ForeignKeyViolation: 54 | props, table := parsePgError(pgErr.Detail) 55 | var message string 56 | if strings.Contains(pgErr.Detail, "is not present in table") { 57 | message = fmt.Sprintf("{%s} does not reference an existing record in '%s'", strings.Join(props, ","), table) 58 | } else { 59 | message = pgErr.Detail 60 | } 61 | return NewDBError(errors.New("foreign key violation: " + message)) 62 | case pgerrcode.UniqueViolation: 63 | props, _ := parsePgError(pgErr.Detail) 64 | var message string 65 | if strings.Contains(pgErr.Detail, "already exists") { 66 | message = fmt.Sprintf("{%s} already exists", strings.Join(props, ",")) 67 | } else { 68 | message = pgErr.Detail 69 | } 70 | return NewDBError(errors.New("unique constraint violation: " + message)) 71 | } 72 | } 73 | 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /pkg/tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/webhookx-io/webhookx/config/modules" 8 | "go.opentelemetry.io/otel" 9 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 10 | "go.opentelemetry.io/otel/trace" 11 | "go.opentelemetry.io/otel/trace/noop" 12 | ) 13 | 14 | func New(conf *modules.TracingConfig) (*Tracer, error) { 15 | if !conf.Enabled { 16 | otel.SetTracerProvider(noop.NewTracerProvider()) 17 | return nil, nil 18 | } 19 | 20 | tr, err := SetupOTEL(conf) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return NewTracer(tr), nil 26 | } 27 | 28 | func TracerFromContext(ctx context.Context) trace.Tracer { 29 | var tp trace.TracerProvider 30 | if !trace.SpanContextFromContext(ctx).IsValid() { 31 | tp = otel.GetTracerProvider() 32 | } else { 33 | tp = trace.SpanFromContext(ctx).TracerProvider() 34 | } 35 | 36 | return tp.Tracer(instrumentationName) 37 | } 38 | 39 | type Tracer struct { 40 | trace.TracerProvider 41 | } 42 | 43 | func NewTracer(tracerProvider trace.TracerProvider) *Tracer { 44 | return &Tracer{ 45 | TracerProvider: tracerProvider, 46 | } 47 | 48 | } 49 | 50 | func (t *Tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { 51 | if t == nil { 52 | return ctx, trace.SpanFromContext(ctx) 53 | } 54 | tracer := t.Tracer(instrumentationName) 55 | spanCtx, span := tracer.Start(ctx, spanName, opts...) 56 | return spanCtx, span 57 | } 58 | 59 | func (t *Tracer) Stop() error { 60 | if t == nil { 61 | return nil 62 | } 63 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second)) 64 | defer cancel() 65 | 66 | if pr, ok := t.TracerProvider.(*sdktrace.TracerProvider); ok { 67 | return pr.Shutdown(ctx) 68 | } 69 | return nil 70 | } 71 | 72 | func Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { 73 | tracer := TracerFromContext(ctx) 74 | return tracer.Start(ctx, spanName, opts...) 75 | } 76 | --------------------------------------------------------------------------------