├── .air.toml ├── .dockerignore ├── .env.example ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── cmd ├── main.go └── server │ └── server.go ├── config └── config.go ├── db └── migrations │ ├── 20250410103729_create_webhooks_table.sql │ ├── 20250410131118_update_webhooks_table.sql │ ├── 20250410182719_create_webhooks_requests_table.sql │ └── 20250410190532_create_webhooks_request_update.sql ├── docker-compose.yml ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── go.mod ├── go.sum ├── internal ├── db │ └── connection.go ├── dtos │ └── dto.go ├── handlers │ ├── auth.go │ ├── home.go │ ├── pages.go │ ├── webhook.go │ ├── webhook_api.go │ └── webhook_requests.go ├── metrics │ ├── interface.go │ ├── metrics.go │ └── prometheus_recorder.go ├── middlewares │ └── middleware.go ├── models │ ├── user.go │ ├── webhook.go │ └── webhook_request.go ├── repository │ ├── user.go │ ├── webhook.go │ └── webhook_request.go ├── routers │ ├── api.go │ ├── web.go │ └── webhook.go ├── service │ ├── auth.go │ ├── webhook.go │ └── webhook_request.go ├── store │ ├── user.go │ ├── webhook.go │ └── webhook_request.go ├── utils │ ├── api_key.go │ ├── id.go │ ├── passwords.go │ ├── render_html.go │ ├── render_json.go │ └── util.go └── web │ └── templates │ ├── base.html │ ├── embed.go │ ├── forgot-password.html │ ├── home.html │ ├── landing.html │ ├── layout.html │ ├── login.html │ ├── policy.html │ ├── register.html │ ├── request.html │ ├── reset-password.html │ └── terms.html ├── monitoring ├── prometheus.yml └── promtail │ └── config.yml └── static ├── icons ├── eye-off-outline.svg └── eye-outline.svg └── js └── script.js /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "tmp" 3 | 4 | [build] 5 | # Build the main Go binary from cmd/server 6 | cmd = "go build -o ./tmp/server ./cmd" 7 | bin = "tmp/server" 8 | full_bin = "" 9 | delay = 1000 10 | exclude_dir = ["tmp", "static", "node_modules", "dist"] 11 | include_ext = ["go", "tpl", "tmpl", "html", "json", "yaml"] 12 | exclude_ext = ["swp", "tmp"] 13 | log = "air.log" 14 | 15 | [log] 16 | time = true 17 | 18 | [color] 19 | main = "magenta" 20 | watcher = "cyan" 21 | build = "yellow" 22 | runner = "green" 23 | 24 | [misc] 25 | clean_on_exit = true -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | webhook-tester 3 | *.db 4 | 5 | # Git & editor 6 | .git 7 | .vscode 8 | .idea 9 | 10 | # Dependencies 11 | vendor/ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ENV=prod 2 | POSTGRES_USER=postgres 3 | POSTGRES_PASSWORD=postgres 4 | POSTGRES_DB=webhook_tester 5 | DB_HOST=db 6 | DB_PORT=5432 7 | DOMAIN=http://localhost:3000 8 | # Must be 32 bytes 9 | AUTH_SECRET=oqO+IHqktGEU/CRnCjSu/C5sUpEKn+YnTHcT31ujWOg= -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Go Build & Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: 📥 Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - name: 🧰 Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: '1.21' 21 | 22 | - name: ✅ Run build 23 | run: go build ./... 24 | 25 | # - name: 🧪 Run tests 26 | # run: go test ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | # Intellij IDEs 28 | .idea/ 29 | 30 | # sqlite db 31 | *.db 32 | *.sqlite 33 | 34 | # tmp 35 | tmp/ 36 | *.log -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ───── Stage 1: Build ───── 2 | FROM golang:1.24-alpine AS base 3 | 4 | WORKDIR /app 5 | 6 | COPY . . 7 | 8 | RUN go mod download 9 | 10 | # Build the Go binary 11 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o webhook-tester ./cmd/main.go 12 | 13 | # ───── Stage 2: Final ───── 14 | FROM scratch 15 | 16 | WORKDIR /app 17 | 18 | # Copy the built binary 19 | COPY --from=base /app/webhook-tester . 20 | 21 | # Copy static assets, migrations and docs 22 | COPY static/ static/ 23 | COPY db/migrations/ db/migrations/ 24 | COPY docs/ docs/ 25 | 26 | EXPOSE 3000 27 | 28 | # Command to run 29 | CMD ["./webhook-tester"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Project variables 2 | SWAG_CMD=swag 3 | SWAG_OUT=docs 4 | SWAG_MAIN=cmd/main.go 5 | APP_NAME=webhook-tester 6 | DOCKER_COMPOSE=docker-compose 7 | 8 | .PHONY: help up down logs restart services docs 9 | 10 | docs: 11 | @echo "🔄 Generating Swagger docs..." 12 | $(SWAG_CMD) init --parseDependency --parseInternal -g $(SWAG_MAIN) 13 | @echo "✅ Swagger docs generated in ./$(SWAG_OUT)" 14 | 15 | # Start all services 16 | up: 17 | $(DOCKER_COMPOSE) up -d 18 | 19 | # Stop all services 20 | down: 21 | $(DOCKER_COMPOSE) down 22 | 23 | # Build all services 24 | build: 25 | $(DOCKER_COMPOSE) build --no-cache 26 | 27 | # Status of services 28 | ps: 29 | $(DOCKER_COMPOSE) ps 30 | 31 | # Reset everything (⚠️ destructive) 32 | reset: 33 | $(DOCKER_COMPOSE) down -v --remove-orphans 34 | 35 | # Dynamic targets: make restart SERVICE=name 36 | restart: 37 | @$(DOCKER_COMPOSE) restart $(SERVICE) 38 | 39 | logs: 40 | @$(DOCKER_COMPOSE) logs -f $(SERVICE) 41 | 42 | sh: 43 | @$(DOCKER_COMPOSE) exec $(SERVICE) sh 44 | 45 | # List all service names from docker-compose 46 | services: 47 | @echo "Available services:" 48 | @$(DOCKER_COMPOSE) config --services 49 | 50 | # Example helper 51 | help: 52 | @echo "Usage: make [target] [SERVICE=service_name]" 53 | @echo "" 54 | @echo "Available targets:" 55 | @echo " up Build and start the app with Docker Compose" 56 | @echo " down Stop and remove containers" 57 | @echo " logs View logs (requires SERVICE=app or SERVICE=db)" 58 | @echo " restart Restart a specific service (requires SERVICE=app or SERVICE=db)" 59 | @echo " services List available service names" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧪 Webhook Tester 2 | 3 | A lightweight, developer-friendly platform for testing and debugging webhooks — built in Go. 4 | 5 | This project allows developers to create unique webhook endpoints, capture incoming requests, inspect headers and 6 | payloads, and optionally replay those requests. 7 | 8 | --- 9 | 10 | ## ✨ Features 11 | 12 | - 📩 Receive webhooks at unique URLs 13 | - 🔍 Inspect request payloads (headers, body, method, query params) 14 | - 💾 Log and view webhook events in real-time 15 | - 🛠️ Customize responses (status code, content type, payload, delay) 16 | - 🔁 Replay events 17 | - 🔐 API to manage webhooks 18 | - 📚 Swagger API documentation 19 | - 🧪 Built for testing, mocking, and debugging external integrations 20 | 21 | --- 22 | 23 | ## 🏃♂️ Getting Started (Manual) 24 | 25 | ### 1. Clone & Configure 26 | 27 | ```bash 28 | git clone https://github.com/muliswilliam/webhook-tester 29 | cd webhook-tester 30 | cp .env.example .env 31 | ``` 32 | 33 | ### 2. Generate AUTH_SECRET 34 | 35 | Auth secret must be 32 bytes. Generate one with: 36 | 37 | ```bash 38 | openssl rand -base64 32 39 | ``` 40 | Paste it into your .env file. 41 | 42 | ### 3. Run Locally 43 | 44 | ```bash 45 | go run cmd/main.go 46 | ``` 47 | 48 | Visit: http://localhost:3000 49 | 50 | --- 51 | 52 | ## 🐳 Running with Docker (Recommended) 53 | 54 | 1. Set environment variables & Start Services 55 | ```bash 56 | cp .env.example .env 57 | make up 58 | ``` 59 | 60 | This builds the Docker image and starts all services using docker-compose. 61 | 62 | 2. View Logs for a Specific Service 63 | ```bash 64 | make logs SERVICE=app 65 | make logs SERVICE=db 66 | ``` 67 | 68 | 3. Restart a Specific Service 69 | 70 | ```bash 71 | make restart SERVICE=app 72 | make restart SERVICE=db 73 | ``` 74 | 75 | 4. Stop All Services 76 | ```bash 77 | make down 78 | ``` 79 | 80 | 5. List Available Services 81 | ```bash 82 | make services 83 | ``` 84 | 85 | 6. View Available Commands 86 | 87 | Run the help command to see what you can do with `make`: 88 | 89 | ```bash 90 | make help 91 | ``` 92 | 93 | --- 94 | 95 | 📁 Project Structure 96 | 97 | cmd/ # App entrypoint 98 | internal/ # Handlers, models, db logic, templates 99 | docs/ # Swagger documentation 100 | static/ # JS, icons, etc. 101 | db/migrations/ # SQL migrations 102 | Makefile # Dev & deployment automation 103 | Dockerfile # Production build config 104 | docker-compose.yml 105 | 106 | --- 107 | 108 | 📚 API Documentation 109 | 110 | After running the app, access Swagger docs at: 111 | 112 | http://localhost:3000/docs 113 | 114 | API endpoints require a valid API key sent via X-API-Key header. 115 | 116 | --- 117 | 118 | 📌 Roadmap 119 | - ✅ API Authentication with API keys 120 | - ✅ Swagger Documentation 121 | - ✅ Docker + Compose for deployment 122 | - ⏳ Email notifications on request 123 | - ⏳ Rate limiting and abuse protection 124 | - ⏳ Metrics and observability (LGTM stack) 125 | - ⏳ Export logs to JSON/CSV 126 | - ⏳ Team/organization mode for sharing webhooks 127 | 128 | --- 129 | 130 | 🧠 Credits 131 | 132 | Developed with ❤️ by William Muli -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/robfig/cron" 6 | "gorm.io/gorm" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | "webhook-tester/cmd/server" 13 | "webhook-tester/internal/metrics" 14 | ) 15 | 16 | func scheduleCleanup(db *gorm.DB, c *cron.Cron) { 17 | // clean every day 18 | err := c.AddFunc("0 0 * * *", func() { 19 | //sqlstore.CleanPublicWebhooks(db, 48*time.Hour) // 48 hours old 20 | }) 21 | if err != nil { 22 | log.Fatalf("error scheduling cleanup: %s", err) 23 | } 24 | } 25 | 26 | // @title Webhook Tester API 27 | // @version 1.0 28 | // @description REST API to interact with webhooks and webhook requests 29 | 30 | // @contact.name William Muli 31 | // @contact.url 32 | // @contact.email william@srninety.one 33 | // @BasePath /api 34 | // @securityDefinitions.apikey ApiKeyAuth 35 | // @in header 36 | // @name X-API-Key 37 | func main() { 38 | s := server.NewServer() 39 | s.MountHandlers() 40 | metrics.Register() 41 | 42 | go func() { 43 | err := s.Srv.ListenAndServe() 44 | if err != nil { 45 | s.Logger.Fatal(err) 46 | } 47 | }() 48 | 49 | s.Logger.Printf("server listening on port 3000") 50 | 51 | // cron setup 52 | c := cron.New() 53 | scheduleCleanup(s.DB, c) 54 | c.Start() 55 | defer c.Stop() 56 | 57 | quit := make(chan os.Signal, 1) 58 | signal.Notify(quit, os.Interrupt) 59 | signal.Notify(quit, syscall.SIGTERM) 60 | 61 | shut := <-quit 62 | s.Logger.Printf("shutting down by signal: %s", shut.String()) 63 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 64 | defer cancel() 65 | 66 | if err := s.Srv.Shutdown(ctx); err != nil { 67 | s.Logger.Printf("graceful shutdown failed: %s", err) 68 | } 69 | 70 | s.Logger.Printf("server stopped") 71 | } 72 | -------------------------------------------------------------------------------- /cmd/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "github.com/MarceloPetrucio/go-scalar-api-reference" 6 | "github.com/go-chi/chi/v5" 7 | "github.com/go-chi/chi/v5/middleware" 8 | "github.com/go-chi/cors" 9 | "github.com/prometheus/client_golang/prometheus/promhttp" 10 | metrics "github.com/slok/go-http-metrics/metrics/prometheus" 11 | metricsMiddleware "github.com/slok/go-http-metrics/middleware" 12 | "webhook-tester/internal/routers" 13 | "webhook-tester/internal/service" 14 | "webhook-tester/internal/store" 15 | 16 | "github.com/slok/go-http-metrics/middleware/std" 17 | "github.com/wader/gormstore/v2" 18 | 19 | "gorm.io/gorm" 20 | "log" 21 | "net/http" 22 | "os" 23 | "time" 24 | "webhook-tester/config" 25 | _ "webhook-tester/docs" 26 | "webhook-tester/internal/db" 27 | appMetrics "webhook-tester/internal/metrics" 28 | ) 29 | 30 | type Server struct { 31 | Router *chi.Mux 32 | DB *gorm.DB 33 | SessionStore *gormstore.Store 34 | Logger *log.Logger 35 | Srv *http.Server 36 | } 37 | 38 | func (srv *Server) MountHandlers() { 39 | r := srv.Router 40 | authSecret := os.Getenv("AUTH_SECRET") 41 | repo := store.NewGormWebookRepo(srv.DB, srv.Logger) 42 | userRepo := store.NewGormUserRepo(srv.DB, srv.Logger) 43 | webhookReqRepo := store.NewGormWebhookRequestRepo(srv.DB, srv.Logger) 44 | webhookSvc := service.NewWebhookService(repo) 45 | webhookReqSvc := service.NewWebhookRequestService(webhookReqRepo) 46 | authSvc := service.NewAuthService(userRepo, srv.DB, authSecret) 47 | metricsRec := appMetrics.PrometheusRecorder{} 48 | // Basic CORS 49 | // for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing 50 | r.Use(cors.Handler(cors.Options{ 51 | AllowedOrigins: []string{"https://*", "http://*"}, 52 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 53 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, 54 | ExposedHeaders: []string{"Link"}, 55 | AllowCredentials: false, 56 | MaxAge: 300, // Maximum value not ignored by any of major browsers 57 | })) 58 | 59 | r.Use(middleware.Logger) 60 | r.Use(middleware.RealIP) 61 | r.Use(middleware.Recoverer) 62 | r.Use(middleware.Heartbeat("/health")) 63 | 64 | mdlw := metricsMiddleware.New(metricsMiddleware.Config{ 65 | Recorder: metrics.NewRecorder(metrics.Config{}), 66 | }) 67 | 68 | // Instrument all routes 69 | r.Use(std.HandlerProvider("", mdlw)) 70 | 71 | // Static file server for /static/* 72 | fs := http.FileServer(http.Dir("static")) 73 | r.Handle("/static/*", http.StripPrefix("/static/", fs)) 74 | 75 | r.Mount("/", routers.NewWebRouter(webhookReqSvc, webhookSvc, authSvc, &metricsRec, srv.Logger)) 76 | 77 | r.Mount("/api", routers.NewApiRouter(webhookSvc, authSvc, srv.Logger, &metricsRec)) 78 | r.Mount("/webhooks", routers.NewWebhookRouter(webhookSvc, authSvc, srv.Logger, &metricsRec)) 79 | 80 | // metrics 81 | r.Handle("/metrics", promhttp.Handler()) 82 | 83 | // API documentation 84 | r.Get("/docs", func(w http.ResponseWriter, r *http.Request) { 85 | htmlContent, err := scalar.ApiReferenceHTML(&scalar.Options{ 86 | SpecURL: "./docs/swagger.json", 87 | CustomOptions: scalar.CustomOptions{ 88 | PageTitle: "Simple API", 89 | }, 90 | DarkMode: true, 91 | }) 92 | 93 | if err != nil { 94 | fmt.Printf("%v", err) 95 | } 96 | 97 | fmt.Fprintln(w, htmlContent) 98 | }) 99 | } 100 | 101 | func NewServer() *Server { 102 | config.LoadEnv() 103 | conn := db.Connect() 104 | db.AutoMigrate(conn) 105 | 106 | r := chi.NewRouter() 107 | srv := http.Server{ 108 | Addr: ":3000", 109 | Handler: r, 110 | IdleTimeout: time.Minute, 111 | } 112 | 113 | return &Server{ 114 | Router: r, 115 | DB: conn, 116 | Logger: log.New(os.Stdout, "[server] ", log.LstdFlags), 117 | Srv: &srv, 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/joho/godotenv" 5 | "log" 6 | ) 7 | 8 | func LoadEnv() { 9 | if err := godotenv.Load(); err != nil { 10 | log.Println("No .env found - using defaults") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /db/migrations/20250410103729_create_webhooks_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE webhooks 4 | ( 5 | id TEXT PRIMARY KEY, 6 | title TEXT NOT NULL, 7 | response_code INTEGER NOT NULL DEFAULT 200, 8 | content_type TEXT NOT NULL, 9 | response_delay INTEGER NOT NULL DEFAULT 0, 10 | payload TEXT NOT NULL, 11 | created_at DATETIME NOT NULL 12 | ); 13 | -- +goose StatementEnd 14 | 15 | -- +goose Down 16 | -- +goose StatementBegin 17 | DROP TABLE IF EXISTS webhooks; 18 | -- +goose StatementEnd 19 | -------------------------------------------------------------------------------- /db/migrations/20250410131118_update_webhooks_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE webhooks ADD updated_at DATETIME; 4 | ALTER TABLE webhooks ADD notify_on_event INTEGER DEFAULT 0; 5 | 6 | -- +goose StatementEnd 7 | 8 | -- +goose Down 9 | -- +goose StatementBegin 10 | ALTER TABLE webhooks DROP COLUMN updated_at; 11 | ALTER TABLE webhooks DROP COLUMN notify_on_event; 12 | -- +goose StatementEnd 13 | -------------------------------------------------------------------------------- /db/migrations/20250410182719_create_webhooks_requests_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE webhook_requests 4 | ( 5 | id TEXT PRIMARY KEY, 6 | webhook_id TEXT NOT NULL, 7 | method TEXT NOT NULL, 8 | body TEXT, 9 | headers TEXT, 10 | query TEXT, 11 | created_at DATETIME NOT NULL, 12 | FOREIGN KEY (webhook_id) REFERENCES webhooks(id) 13 | ); 14 | -- +goose StatementEnd 15 | 16 | -- +goose Down 17 | -- +goose StatementBegin 18 | DROP TABLE IF EXISTS webhook_requests; 19 | -- +goose StatementEnd 20 | -------------------------------------------------------------------------------- /db/migrations/20250410190532_create_webhooks_request_update.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | SELECT 'up SQL query'; 4 | ALTER TABLE webhook_requests DROP COLUMN created_at; 5 | ALTER TABLE webhook_requests 6 | ADD received_at DATETIME; 7 | -- +goose StatementEnd 8 | 9 | -- +goose Down 10 | -- +goose StatementBegin 11 | ALTER TABLE webhook_requests 12 | ADD created_at DATETIME NOT NULL; 13 | ALTER TABLE webhook_requests DROP COLUMN received_at; 14 | -- +goose StatementEnd 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "3000:3000" 8 | depends_on: 9 | db: 10 | condition: service_healthy 11 | environment: 12 | ENV: ${ENV} 13 | DOMAIN: ${DOMAIN} 14 | POSTGRES_USER: ${POSTGRES_USER} 15 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 16 | POSTGRES_DB: ${POSTGRES_DB} 17 | DB_HOST: ${DB_HOST} 18 | DB_PORT: ${DB_PORT} 19 | AUTH_SECRET: ${AUTH_SECRET} 20 | restart: unless-stopped 21 | 22 | db: 23 | image: postgres:15-alpine 24 | restart: unless-stopped 25 | environment: 26 | POSTGRES_USER: ${POSTGRES_USER} 27 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 28 | POSTGRES_DB: ${POSTGRES_DB} 29 | PGPORT: ${DB_PORT} 30 | volumes: 31 | - pgdata:/var/lib/postgresql/data 32 | healthcheck: 33 | test: ["CMD-SHELL", "pg_isready -h ${DB_HOST} -U ${POSTGRES_USER}"] 34 | interval: 5s 35 | timeout: 5s 36 | retries: 5 37 | prometheus: 38 | image: prom/prometheus:latest 39 | volumes: 40 | - prometheus-data:/prometheus 41 | - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml 42 | command: 43 | - --config.file=/etc/prometheus/prometheus.yml 44 | ports: 45 | - "9090:9090" 46 | node-exporter: 47 | image: prom/node-exporter:latest 48 | container_name: node-exporter 49 | restart: unless-stopped 50 | ports: 51 | - "9100:9100" 52 | volumes: 53 | - /proc:/host/proc:ro 54 | - /sys:/host/sys:ro 55 | - /:/rootfs:ro 56 | command: 57 | - "--path.procfs=/host/proc" 58 | - "--path.sysfs=/host/sys" 59 | - "--path.rootfs=/rootfs" 60 | 61 | loki: 62 | image: grafana/loki:2.9.3 63 | command: -config.file=/etc/loki/local-config.yaml 64 | ports: 65 | - "3100:3100" 66 | 67 | promtail: 68 | image: grafana/promtail:2.9.3 69 | container_name: promtail 70 | volumes: 71 | - /var/log:/var/log 72 | - /etc/machine-id:/etc/machine-id:ro 73 | - /etc/hostname:/etc/hostname:ro 74 | - ./monitoring/promtail/config.yml:/etc/promtail/config.yml 75 | - /var/lib/docker/containers:/var/lib/docker/containers:ro 76 | command: -config.file=/etc/promtail/config.yml 77 | depends_on: 78 | - loki 79 | 80 | grafana: 81 | image: grafana/grafana:latest 82 | ports: 83 | - "3001:3000" 84 | volumes: 85 | - grafana-data:/var/lib/grafana 86 | environment: 87 | - GF_SECURITY_ADMIN_PASSWORD=admin 88 | - GF_SECURITY_ADMIN_USER=admin 89 | depends_on: 90 | - prometheus 91 | - loki 92 | 93 | volumes: 94 | pgdata: 95 | grafana-data: 96 | prometheus-data: 97 | -------------------------------------------------------------------------------- /docs/docs.go: -------------------------------------------------------------------------------- 1 | // Package docs Code generated by swaggo/swag. DO NOT EDIT 2 | package docs 3 | 4 | import "github.com/swaggo/swag" 5 | 6 | const docTemplate = `{ 7 | "schemes": {{ marshal .Schemes }}, 8 | "swagger": "2.0", 9 | "info": { 10 | "description": "{{escape .Description}}", 11 | "title": "{{.Title}}", 12 | "contact": { 13 | "name": "William Muli", 14 | "email": "william@srninety.one" 15 | }, 16 | "version": "{{.Version}}" 17 | }, 18 | "host": "{{.Host}}", 19 | "basePath": "{{.BasePath}}", 20 | "paths": { 21 | "/webhooks": { 22 | "get": { 23 | "security": [ 24 | { 25 | "ApiKeyAuth": [] 26 | } 27 | ], 28 | "description": "List webhooks and associated request", 29 | "produces": [ 30 | "application/json" 31 | ], 32 | "tags": [ 33 | "Webhooks" 34 | ], 35 | "summary": "List webhooks", 36 | "responses": { 37 | "200": { 38 | "description": "OK", 39 | "schema": { 40 | "type": "array", 41 | "items": { 42 | "$ref": "#/definitions/Webhook" 43 | } 44 | } 45 | } 46 | } 47 | }, 48 | "post": { 49 | "security": [ 50 | { 51 | "ApiKeyAuth": [] 52 | } 53 | ], 54 | "description": "Returns the details of the created webhook", 55 | "produces": [ 56 | "application/json" 57 | ], 58 | "tags": [ 59 | "Webhooks" 60 | ], 61 | "summary": "Create a webhook", 62 | "parameters": [ 63 | { 64 | "description": "Webhook body", 65 | "name": "webhook", 66 | "in": "body", 67 | "required": true, 68 | "schema": { 69 | "$ref": "#/definitions/CreateWebhookRequest" 70 | } 71 | } 72 | ], 73 | "responses": { 74 | "200": { 75 | "description": "OK", 76 | "schema": { 77 | "$ref": "#/definitions/Webhook" 78 | } 79 | } 80 | } 81 | } 82 | }, 83 | "/webhooks/{id}": { 84 | "get": { 85 | "security": [ 86 | { 87 | "ApiKeyAuth": [] 88 | } 89 | ], 90 | "description": "Get a webhook by ID along with its requests", 91 | "produces": [ 92 | "application/json" 93 | ], 94 | "tags": [ 95 | "Webhooks" 96 | ], 97 | "summary": "Get webhook by ID", 98 | "parameters": [ 99 | { 100 | "type": "string", 101 | "description": "Webhook ID", 102 | "name": "id", 103 | "in": "path", 104 | "required": true 105 | } 106 | ], 107 | "responses": { 108 | "200": { 109 | "description": "OK", 110 | "schema": { 111 | "type": "array", 112 | "items": { 113 | "$ref": "#/definitions/Webhook" 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | "put": { 120 | "security": [ 121 | { 122 | "ApiKeyAuth": [] 123 | } 124 | ], 125 | "description": "Updates a webhook", 126 | "produces": [ 127 | "application/json" 128 | ], 129 | "tags": [ 130 | "Webhooks" 131 | ], 132 | "summary": "Updates a webhook", 133 | "parameters": [ 134 | { 135 | "type": "string", 136 | "description": "Webhook ID", 137 | "name": "id", 138 | "in": "path", 139 | "required": true 140 | }, 141 | { 142 | "description": "Updated webhook", 143 | "name": "webhook", 144 | "in": "body", 145 | "required": true, 146 | "schema": { 147 | "$ref": "#/definitions/UpdateWebhookRequest" 148 | } 149 | } 150 | ], 151 | "responses": { 152 | "200": { 153 | "description": "OK", 154 | "schema": { 155 | "type": "array", 156 | "items": { 157 | "$ref": "#/definitions/Webhook" 158 | } 159 | } 160 | } 161 | } 162 | }, 163 | "delete": { 164 | "security": [ 165 | { 166 | "ApiKeyAuth": [] 167 | } 168 | ], 169 | "description": "Deletes a webhook", 170 | "produces": [ 171 | "application/json" 172 | ], 173 | "tags": [ 174 | "Webhooks" 175 | ], 176 | "summary": "Delete a webhook", 177 | "parameters": [ 178 | { 179 | "type": "string", 180 | "description": "Webhook ID", 181 | "name": "id", 182 | "in": "path", 183 | "required": true 184 | } 185 | ], 186 | "responses": { 187 | "204": { 188 | "description": "No Content", 189 | "schema": { 190 | "type": "string" 191 | } 192 | }, 193 | "500": { 194 | "description": "Internal Server Error", 195 | "schema": { 196 | "$ref": "#/definitions/ErrorResponse" 197 | } 198 | } 199 | } 200 | } 201 | } 202 | }, 203 | "definitions": { 204 | "CreateWebhookRequest": { 205 | "type": "object", 206 | "properties": { 207 | "content_type": { 208 | "type": "string" 209 | }, 210 | "notify_on_event": { 211 | "type": "boolean" 212 | }, 213 | "payload": { 214 | "type": "string" 215 | }, 216 | "response_code": { 217 | "type": "integer" 218 | }, 219 | "response_delay": { 220 | "description": "milliseconds", 221 | "type": "integer" 222 | }, 223 | "title": { 224 | "description": "Title of the webhook\nrequired: true", 225 | "type": "string" 226 | } 227 | } 228 | }, 229 | "ErrorResponse": { 230 | "type": "object", 231 | "properties": { 232 | "error": { 233 | "type": "string", 234 | "example": "Webhook not found" 235 | } 236 | } 237 | }, 238 | "UpdateWebhookRequest": { 239 | "type": "object", 240 | "properties": { 241 | "content_type": { 242 | "type": "string" 243 | }, 244 | "notify_on_event": { 245 | "type": "boolean" 246 | }, 247 | "payload": { 248 | "type": "string" 249 | }, 250 | "response_code": { 251 | "type": "integer" 252 | }, 253 | "response_delay": { 254 | "description": "milliseconds", 255 | "type": "integer" 256 | }, 257 | "title": { 258 | "description": "Title of the webhook\nrequired: true", 259 | "type": "string" 260 | } 261 | } 262 | }, 263 | "Webhook": { 264 | "type": "object", 265 | "properties": { 266 | "content_type": { 267 | "type": "string" 268 | }, 269 | "created_at": { 270 | "type": "string" 271 | }, 272 | "id": { 273 | "type": "string" 274 | }, 275 | "notify_on_event": { 276 | "type": "boolean" 277 | }, 278 | "payload": { 279 | "type": "string" 280 | }, 281 | "requests": { 282 | "type": "array", 283 | "items": { 284 | "$ref": "#/definitions/WebhookRequest" 285 | } 286 | }, 287 | "response_code": { 288 | "type": "integer" 289 | }, 290 | "response_delay": { 291 | "description": "milliseconds", 292 | "type": "integer" 293 | }, 294 | "title": { 295 | "type": "string" 296 | }, 297 | "updated_at": { 298 | "type": "string" 299 | }, 300 | "user_id": { 301 | "type": "integer" 302 | } 303 | } 304 | }, 305 | "WebhookRequest": { 306 | "type": "object", 307 | "properties": { 308 | "body": { 309 | "type": "string" 310 | }, 311 | "headers": { 312 | "$ref": "#/definitions/datatypes.JSONMap" 313 | }, 314 | "id": { 315 | "type": "string" 316 | }, 317 | "method": { 318 | "type": "string" 319 | }, 320 | "query": { 321 | "$ref": "#/definitions/datatypes.JSONMap" 322 | }, 323 | "received_at": { 324 | "type": "string" 325 | }, 326 | "webhook_id": { 327 | "type": "string" 328 | } 329 | } 330 | }, 331 | "datatypes.JSONMap": { 332 | "type": "object", 333 | "additionalProperties": true 334 | } 335 | }, 336 | "securityDefinitions": { 337 | "ApiKeyAuth": { 338 | "type": "apiKey", 339 | "name": "X-API-Key", 340 | "in": "header" 341 | } 342 | } 343 | }` 344 | 345 | // SwaggerInfo holds exported Swagger Info so clients can modify it 346 | var SwaggerInfo = &swag.Spec{ 347 | Version: "1.0", 348 | Host: "", 349 | BasePath: "/api", 350 | Schemes: []string{}, 351 | Title: "Webhook Tester API", 352 | Description: "REST API to interact with webhooks and webhook requests", 353 | InfoInstanceName: "swagger", 354 | SwaggerTemplate: docTemplate, 355 | LeftDelim: "{{", 356 | RightDelim: "}}", 357 | } 358 | 359 | func init() { 360 | swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) 361 | } 362 | -------------------------------------------------------------------------------- /docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "REST API to interact with webhooks and webhook requests", 5 | "title": "Webhook Tester API", 6 | "contact": { 7 | "name": "William Muli", 8 | "email": "william@srninety.one" 9 | }, 10 | "version": "1.0" 11 | }, 12 | "basePath": "/api", 13 | "paths": { 14 | "/webhooks": { 15 | "get": { 16 | "security": [ 17 | { 18 | "ApiKeyAuth": [] 19 | } 20 | ], 21 | "description": "List webhooks and associated request", 22 | "produces": [ 23 | "application/json" 24 | ], 25 | "tags": [ 26 | "Webhooks" 27 | ], 28 | "summary": "List webhooks", 29 | "responses": { 30 | "200": { 31 | "description": "OK", 32 | "schema": { 33 | "type": "array", 34 | "items": { 35 | "$ref": "#/definitions/Webhook" 36 | } 37 | } 38 | } 39 | } 40 | }, 41 | "post": { 42 | "security": [ 43 | { 44 | "ApiKeyAuth": [] 45 | } 46 | ], 47 | "description": "Returns the details of the created webhook", 48 | "produces": [ 49 | "application/json" 50 | ], 51 | "tags": [ 52 | "Webhooks" 53 | ], 54 | "summary": "Create a webhook", 55 | "parameters": [ 56 | { 57 | "description": "Webhook body", 58 | "name": "webhook", 59 | "in": "body", 60 | "required": true, 61 | "schema": { 62 | "$ref": "#/definitions/CreateWebhookRequest" 63 | } 64 | } 65 | ], 66 | "responses": { 67 | "200": { 68 | "description": "OK", 69 | "schema": { 70 | "$ref": "#/definitions/Webhook" 71 | } 72 | } 73 | } 74 | } 75 | }, 76 | "/webhooks/{id}": { 77 | "get": { 78 | "security": [ 79 | { 80 | "ApiKeyAuth": [] 81 | } 82 | ], 83 | "description": "Get a webhook by ID along with its requests", 84 | "produces": [ 85 | "application/json" 86 | ], 87 | "tags": [ 88 | "Webhooks" 89 | ], 90 | "summary": "Get webhook by ID", 91 | "parameters": [ 92 | { 93 | "type": "string", 94 | "description": "Webhook ID", 95 | "name": "id", 96 | "in": "path", 97 | "required": true 98 | } 99 | ], 100 | "responses": { 101 | "200": { 102 | "description": "OK", 103 | "schema": { 104 | "type": "array", 105 | "items": { 106 | "$ref": "#/definitions/Webhook" 107 | } 108 | } 109 | } 110 | } 111 | }, 112 | "put": { 113 | "security": [ 114 | { 115 | "ApiKeyAuth": [] 116 | } 117 | ], 118 | "description": "Updates a webhook", 119 | "produces": [ 120 | "application/json" 121 | ], 122 | "tags": [ 123 | "Webhooks" 124 | ], 125 | "summary": "Updates a webhook", 126 | "parameters": [ 127 | { 128 | "type": "string", 129 | "description": "Webhook ID", 130 | "name": "id", 131 | "in": "path", 132 | "required": true 133 | }, 134 | { 135 | "description": "Updated webhook", 136 | "name": "webhook", 137 | "in": "body", 138 | "required": true, 139 | "schema": { 140 | "$ref": "#/definitions/UpdateWebhookRequest" 141 | } 142 | } 143 | ], 144 | "responses": { 145 | "200": { 146 | "description": "OK", 147 | "schema": { 148 | "type": "array", 149 | "items": { 150 | "$ref": "#/definitions/Webhook" 151 | } 152 | } 153 | } 154 | } 155 | }, 156 | "delete": { 157 | "security": [ 158 | { 159 | "ApiKeyAuth": [] 160 | } 161 | ], 162 | "description": "Deletes a webhook", 163 | "produces": [ 164 | "application/json" 165 | ], 166 | "tags": [ 167 | "Webhooks" 168 | ], 169 | "summary": "Delete a webhook", 170 | "parameters": [ 171 | { 172 | "type": "string", 173 | "description": "Webhook ID", 174 | "name": "id", 175 | "in": "path", 176 | "required": true 177 | } 178 | ], 179 | "responses": { 180 | "204": { 181 | "description": "No Content", 182 | "schema": { 183 | "type": "string" 184 | } 185 | }, 186 | "500": { 187 | "description": "Internal Server Error", 188 | "schema": { 189 | "$ref": "#/definitions/ErrorResponse" 190 | } 191 | } 192 | } 193 | } 194 | } 195 | }, 196 | "definitions": { 197 | "CreateWebhookRequest": { 198 | "type": "object", 199 | "properties": { 200 | "content_type": { 201 | "type": "string" 202 | }, 203 | "notify_on_event": { 204 | "type": "boolean" 205 | }, 206 | "payload": { 207 | "type": "string" 208 | }, 209 | "response_code": { 210 | "type": "integer" 211 | }, 212 | "response_delay": { 213 | "description": "milliseconds", 214 | "type": "integer" 215 | }, 216 | "title": { 217 | "description": "Title of the webhook\nrequired: true", 218 | "type": "string" 219 | } 220 | } 221 | }, 222 | "ErrorResponse": { 223 | "type": "object", 224 | "properties": { 225 | "error": { 226 | "type": "string", 227 | "example": "Webhook not found" 228 | } 229 | } 230 | }, 231 | "UpdateWebhookRequest": { 232 | "type": "object", 233 | "properties": { 234 | "content_type": { 235 | "type": "string" 236 | }, 237 | "notify_on_event": { 238 | "type": "boolean" 239 | }, 240 | "payload": { 241 | "type": "string" 242 | }, 243 | "response_code": { 244 | "type": "integer" 245 | }, 246 | "response_delay": { 247 | "description": "milliseconds", 248 | "type": "integer" 249 | }, 250 | "title": { 251 | "description": "Title of the webhook\nrequired: true", 252 | "type": "string" 253 | } 254 | } 255 | }, 256 | "Webhook": { 257 | "type": "object", 258 | "properties": { 259 | "content_type": { 260 | "type": "string" 261 | }, 262 | "created_at": { 263 | "type": "string" 264 | }, 265 | "id": { 266 | "type": "string" 267 | }, 268 | "notify_on_event": { 269 | "type": "boolean" 270 | }, 271 | "payload": { 272 | "type": "string" 273 | }, 274 | "requests": { 275 | "type": "array", 276 | "items": { 277 | "$ref": "#/definitions/WebhookRequest" 278 | } 279 | }, 280 | "response_code": { 281 | "type": "integer" 282 | }, 283 | "response_delay": { 284 | "description": "milliseconds", 285 | "type": "integer" 286 | }, 287 | "title": { 288 | "type": "string" 289 | }, 290 | "updated_at": { 291 | "type": "string" 292 | }, 293 | "user_id": { 294 | "type": "integer" 295 | } 296 | } 297 | }, 298 | "WebhookRequest": { 299 | "type": "object", 300 | "properties": { 301 | "body": { 302 | "type": "string" 303 | }, 304 | "headers": { 305 | "$ref": "#/definitions/datatypes.JSONMap" 306 | }, 307 | "id": { 308 | "type": "string" 309 | }, 310 | "method": { 311 | "type": "string" 312 | }, 313 | "query": { 314 | "$ref": "#/definitions/datatypes.JSONMap" 315 | }, 316 | "received_at": { 317 | "type": "string" 318 | }, 319 | "webhook_id": { 320 | "type": "string" 321 | } 322 | } 323 | }, 324 | "datatypes.JSONMap": { 325 | "type": "object", 326 | "additionalProperties": true 327 | } 328 | }, 329 | "securityDefinitions": { 330 | "ApiKeyAuth": { 331 | "type": "apiKey", 332 | "name": "X-API-Key", 333 | "in": "header" 334 | } 335 | } 336 | } -------------------------------------------------------------------------------- /docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: /api 2 | definitions: 3 | CreateWebhookRequest: 4 | properties: 5 | content_type: 6 | type: string 7 | notify_on_event: 8 | type: boolean 9 | payload: 10 | type: string 11 | response_code: 12 | type: integer 13 | response_delay: 14 | description: milliseconds 15 | type: integer 16 | title: 17 | description: |- 18 | Title of the webhook 19 | required: true 20 | type: string 21 | type: object 22 | ErrorResponse: 23 | properties: 24 | error: 25 | example: Webhook not found 26 | type: string 27 | type: object 28 | UpdateWebhookRequest: 29 | properties: 30 | content_type: 31 | type: string 32 | notify_on_event: 33 | type: boolean 34 | payload: 35 | type: string 36 | response_code: 37 | type: integer 38 | response_delay: 39 | description: milliseconds 40 | type: integer 41 | title: 42 | description: |- 43 | Title of the webhook 44 | required: true 45 | type: string 46 | type: object 47 | Webhook: 48 | properties: 49 | content_type: 50 | type: string 51 | created_at: 52 | type: string 53 | id: 54 | type: string 55 | notify_on_event: 56 | type: boolean 57 | payload: 58 | type: string 59 | requests: 60 | items: 61 | $ref: '#/definitions/WebhookRequest' 62 | type: array 63 | response_code: 64 | type: integer 65 | response_delay: 66 | description: milliseconds 67 | type: integer 68 | title: 69 | type: string 70 | updated_at: 71 | type: string 72 | user_id: 73 | type: integer 74 | type: object 75 | WebhookRequest: 76 | properties: 77 | body: 78 | type: string 79 | headers: 80 | $ref: '#/definitions/datatypes.JSONMap' 81 | id: 82 | type: string 83 | method: 84 | type: string 85 | query: 86 | $ref: '#/definitions/datatypes.JSONMap' 87 | received_at: 88 | type: string 89 | webhook_id: 90 | type: string 91 | type: object 92 | datatypes.JSONMap: 93 | additionalProperties: true 94 | type: object 95 | info: 96 | contact: 97 | email: william@srninety.one 98 | name: William Muli 99 | description: REST API to interact with webhooks and webhook requests 100 | title: Webhook Tester API 101 | version: "1.0" 102 | paths: 103 | /webhooks: 104 | get: 105 | description: List webhooks and associated request 106 | produces: 107 | - application/json 108 | responses: 109 | "200": 110 | description: OK 111 | schema: 112 | items: 113 | $ref: '#/definitions/Webhook' 114 | type: array 115 | security: 116 | - ApiKeyAuth: [] 117 | summary: List webhooks 118 | tags: 119 | - Webhooks 120 | post: 121 | description: Returns the details of the created webhook 122 | parameters: 123 | - description: Webhook body 124 | in: body 125 | name: webhook 126 | required: true 127 | schema: 128 | $ref: '#/definitions/CreateWebhookRequest' 129 | produces: 130 | - application/json 131 | responses: 132 | "200": 133 | description: OK 134 | schema: 135 | $ref: '#/definitions/Webhook' 136 | security: 137 | - ApiKeyAuth: [] 138 | summary: Create a webhook 139 | tags: 140 | - Webhooks 141 | /webhooks/{id}: 142 | delete: 143 | description: Deletes a webhook 144 | parameters: 145 | - description: Webhook ID 146 | in: path 147 | name: id 148 | required: true 149 | type: string 150 | produces: 151 | - application/json 152 | responses: 153 | "204": 154 | description: No Content 155 | schema: 156 | type: string 157 | "500": 158 | description: Internal Server Error 159 | schema: 160 | $ref: '#/definitions/ErrorResponse' 161 | security: 162 | - ApiKeyAuth: [] 163 | summary: Delete a webhook 164 | tags: 165 | - Webhooks 166 | get: 167 | description: Get a webhook by ID along with its requests 168 | parameters: 169 | - description: Webhook ID 170 | in: path 171 | name: id 172 | required: true 173 | type: string 174 | produces: 175 | - application/json 176 | responses: 177 | "200": 178 | description: OK 179 | schema: 180 | items: 181 | $ref: '#/definitions/Webhook' 182 | type: array 183 | security: 184 | - ApiKeyAuth: [] 185 | summary: Get webhook by ID 186 | tags: 187 | - Webhooks 188 | put: 189 | description: Updates a webhook 190 | parameters: 191 | - description: Webhook ID 192 | in: path 193 | name: id 194 | required: true 195 | type: string 196 | - description: Updated webhook 197 | in: body 198 | name: webhook 199 | required: true 200 | schema: 201 | $ref: '#/definitions/UpdateWebhookRequest' 202 | produces: 203 | - application/json 204 | responses: 205 | "200": 206 | description: OK 207 | schema: 208 | items: 209 | $ref: '#/definitions/Webhook' 210 | type: array 211 | security: 212 | - ApiKeyAuth: [] 213 | summary: Updates a webhook 214 | tags: 215 | - Webhooks 216 | securityDefinitions: 217 | ApiKeyAuth: 218 | in: header 219 | name: X-API-Key 220 | type: apiKey 221 | swagger: "2.0" 222 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module webhook-tester 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 7 | github.com/go-chi/chi v1.5.5 8 | github.com/go-chi/chi/v5 v5.2.1 9 | github.com/go-chi/cors v1.2.1 10 | github.com/gorilla/csrf v1.7.3 11 | github.com/joho/godotenv v1.5.1 12 | github.com/matoous/go-nanoid/v2 v2.1.0 13 | github.com/prometheus/client_golang v1.22.0 14 | github.com/robfig/cron v1.2.0 15 | github.com/slok/go-http-metrics v0.13.0 16 | github.com/swaggo/swag v1.16.4 17 | github.com/unrolled/render v1.7.0 18 | gorm.io/datatypes v1.2.5 19 | gorm.io/driver/postgres v1.5.7 20 | gorm.io/gorm v1.25.12 21 | ) 22 | 23 | require ( 24 | github.com/KyleBanks/depth v1.2.1 // indirect 25 | github.com/beorn7/perks v1.0.1 // indirect 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 29 | github.com/go-openapi/jsonreference v0.21.0 // indirect 30 | github.com/go-openapi/spec v0.21.0 // indirect 31 | github.com/go-openapi/swag v0.23.1 // indirect 32 | github.com/jackc/pgpassfile v1.0.0 // indirect 33 | github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect 34 | github.com/jackc/pgx/v5 v5.5.5 // indirect 35 | github.com/jackc/puddle/v2 v2.2.1 // indirect 36 | github.com/josharian/intern v1.0.0 // indirect 37 | github.com/mailru/easyjson v0.9.0 // indirect 38 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 39 | github.com/pmezard/go-difflib v1.0.0 // indirect 40 | github.com/prometheus/client_model v0.6.1 // indirect 41 | github.com/prometheus/common v0.62.0 // indirect 42 | github.com/prometheus/procfs v0.15.1 // indirect 43 | github.com/rogpeppe/go-internal v1.14.1 // indirect 44 | golang.org/x/sync v0.13.0 // indirect 45 | golang.org/x/tools v0.32.0 // indirect 46 | google.golang.org/protobuf v1.36.5 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | 50 | require ( 51 | filippo.io/edwards25519 v1.1.0 // indirect 52 | github.com/fsnotify/fsnotify v1.6.0 // indirect 53 | github.com/go-sql-driver/mysql v1.8.1 // indirect 54 | github.com/google/uuid v1.6.0 // indirect 55 | github.com/gorilla/securecookie v1.1.2 // indirect 56 | github.com/gorilla/sessions v1.4.0 // indirect 57 | github.com/jinzhu/inflection v1.0.0 // indirect 58 | github.com/jinzhu/now v1.1.5 // indirect 59 | github.com/mattn/go-sqlite3 v1.14.27 // indirect 60 | github.com/stretchr/testify v1.10.0 61 | github.com/wader/gormstore/v2 v2.0.3 62 | golang.org/x/crypto v0.37.0 63 | golang.org/x/sys v0.32.0 // indirect 64 | golang.org/x/text v0.24.0 // indirect 65 | gorm.io/driver/mysql v1.5.6 // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= 5 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 6 | github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM= 7 | github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0= 8 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 9 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 10 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 11 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 12 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 13 | github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 14 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 15 | github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 16 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 21 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 22 | github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= 23 | github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= 24 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 25 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 26 | github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= 27 | github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 28 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 29 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 30 | github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= 31 | github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= 32 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 33 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 34 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= 35 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 36 | github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= 37 | github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= 38 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 39 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 40 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 41 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 42 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 43 | github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 44 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= 45 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 46 | github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= 47 | github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= 48 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 49 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 50 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 51 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 52 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 53 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 54 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 55 | github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= 56 | github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 57 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 58 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 59 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 60 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 61 | github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 62 | github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 63 | github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 64 | github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 65 | github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 66 | github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= 67 | github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= 68 | github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= 69 | github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= 70 | github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= 71 | github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= 72 | github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI= 73 | github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= 74 | github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= 75 | github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= 76 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= 77 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 78 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 79 | github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= 80 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= 81 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= 82 | github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 83 | github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 84 | github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 85 | github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 86 | github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 87 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= 88 | github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= 89 | github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 90 | github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= 91 | github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= 92 | github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= 93 | github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= 94 | github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= 95 | github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= 96 | github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= 97 | github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= 98 | github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= 99 | github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw= 100 | github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= 101 | github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 102 | github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 103 | github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 104 | github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 105 | github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 106 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 107 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 108 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 109 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 110 | github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 111 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 112 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 113 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 114 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 115 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 116 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 117 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 118 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 119 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 120 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 121 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 122 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 123 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 124 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 125 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 126 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 127 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 128 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 129 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 130 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 131 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 132 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 133 | github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 134 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 135 | github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 136 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 137 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 138 | github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= 139 | github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= 140 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 141 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 142 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 143 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 144 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 145 | github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 146 | github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 147 | github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 148 | github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= 149 | github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= 150 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 151 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 152 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 153 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 154 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 155 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 156 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 157 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 158 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 159 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 160 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 161 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 162 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 163 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 164 | github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= 165 | github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= 166 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 167 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 168 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 169 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 170 | github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= 171 | github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= 172 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 173 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 174 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 175 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 176 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 177 | github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8= 178 | github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4= 179 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 180 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 181 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 182 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 183 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 184 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 185 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 186 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 187 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 188 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 189 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 190 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 191 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 192 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 193 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 194 | github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= 195 | github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= 196 | github.com/unrolled/render v1.7.0 h1:1yke01/tZiZpiXfUG+zqB+6fq3G4I+KDmnh0EhPq7So= 197 | github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI= 198 | github.com/wader/gormstore/v2 v2.0.3 h1:/29GWPauY8xZkpLnB8hsp+dZfP3ivA9fiDw1YVNTp6U= 199 | github.com/wader/gormstore/v2 v2.0.3/go.mod h1:sr3N3a8F1+PBc3fHoKaphFqDXLRJ9Oe6Yow0HxKFbbg= 200 | github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 201 | go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 202 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 203 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 204 | go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 205 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 206 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 207 | go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 208 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 209 | go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 210 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 211 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 212 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 213 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 214 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 215 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 216 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 217 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 218 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 219 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 220 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 221 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 222 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 223 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 224 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 225 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 226 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 227 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 228 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 229 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 230 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 231 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 232 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 233 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 234 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 235 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 236 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 237 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 238 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 239 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 240 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 241 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 242 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 245 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 246 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 247 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 248 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 249 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 250 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 251 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 252 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 253 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 254 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 255 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 256 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 257 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 258 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 259 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 260 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 261 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 262 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 263 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 264 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 265 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 266 | golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 267 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 268 | golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 269 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 270 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 271 | golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 272 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 273 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 274 | golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 275 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 276 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 277 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 278 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 279 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 280 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 281 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 282 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 283 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 284 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 285 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 286 | gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= 287 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 288 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 289 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 290 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 291 | gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I= 292 | gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4= 293 | gorm.io/driver/mysql v1.4.0/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= 294 | gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= 295 | gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= 296 | gorm.io/driver/postgres v1.4.1/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw= 297 | gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= 298 | gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= 299 | gorm.io/driver/sqlite v1.4.1/go.mod h1:AKZZCAoFfOWHF7Nd685Iq8Uywc0i9sWJlzpoE/INzsw= 300 | gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= 301 | gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= 302 | gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g= 303 | gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g= 304 | gorm.io/gorm v1.23.7/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= 305 | gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= 306 | gorm.io/gorm v1.23.10/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= 307 | gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= 308 | gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 309 | gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= 310 | gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 311 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 312 | -------------------------------------------------------------------------------- /internal/db/connection.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "webhook-tester/internal/models" 8 | 9 | "gorm.io/driver/postgres" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | func Connect() *gorm.DB { 14 | user := os.Getenv("POSTGRES_USER") 15 | pass := os.Getenv("POSTGRES_PASSWORD") 16 | name := os.Getenv("POSTGRES_DB") 17 | host := os.Getenv("DB_HOST") 18 | port := os.Getenv("DB_PORT") 19 | 20 | if port == "" { 21 | port = "5432" 22 | } 23 | 24 | if user == "" || name == "" || host == "" { 25 | log.Fatal("Database credentials are not fully set in environment variables") 26 | } 27 | 28 | dsn := fmt.Sprintf("postgresql://%s:%s@%s:%s/%s", user, pass, host, port, name) 29 | 30 | db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) 31 | if err != nil { 32 | log.Fatalf("failed to connect to database: %v", err) 33 | } 34 | return db 35 | } 36 | 37 | func AutoMigrate(db *gorm.DB) { 38 | err := db.AutoMigrate(&models.Webhook{}, &models.WebhookRequest{}, &models.User{}) 39 | if err != nil { 40 | log.Fatalf("failed to auto-migrate: %v", err) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/dtos/dto.go: -------------------------------------------------------------------------------- 1 | package dtos 2 | 3 | import ( 4 | "time" 5 | "webhook-tester/internal/models" 6 | 7 | "gorm.io/datatypes" 8 | ) 9 | 10 | // CreateWebhookRequest 11 | // swagger:request 12 | type CreateWebhookRequest struct { 13 | // Title of the webhook 14 | // required: true 15 | Title string `json:"title"` 16 | ResponseCode int `json:"response_code"` 17 | ResponseDelay uint `json:"response_delay"` // milliseconds 18 | ContentType string `json:"content_type"` 19 | Payload string `json:"payload"` 20 | NotifyOnEvent bool `json:"notify_on_event"` 21 | } // @name CreateWebhookRequest 22 | 23 | type UpdateWebhookRequest struct { 24 | CreateWebhookRequest 25 | } // @name UpdateWebhookRequest 26 | 27 | // ErrorResponse represents an error payload 28 | type ErrorResponse struct { 29 | Error string `json:"error" example:"Webhook not found"` 30 | } // @name ErrorResponse 31 | 32 | type WebhookRequest struct { 33 | ID string `gorm:"primaryKey" json:"id"` 34 | WebhookID string `json:"webhook_id"` 35 | Method string `json:"method"` 36 | Headers datatypes.JSONMap `json:"headers"` 37 | Query datatypes.JSONMap `json:"query"` 38 | Body string `json:"body"` 39 | ReceivedAt time.Time `json:"received_at"` 40 | } // @name WebhookRequest 41 | 42 | // swagger:model 43 | type Webhook struct { 44 | ID string `gorm:"primaryKey" json:"id"` 45 | Title string `json:"title"` 46 | ResponseCode int `json:"response_code"` 47 | ResponseDelay uint `json:"response_delay"` // milliseconds 48 | ContentType string `json:"content_type"` 49 | Payload string `json:"payload"` 50 | NotifyOnEvent bool `json:"notify_on_event"` 51 | UserID int `json:"user_id"` 52 | CreatedAt time.Time `json:"created_at"` 53 | UpdatedAt time.Time `json:"updated_at"` 54 | Requests []models.WebhookRequest 55 | } // @name Webhook 56 | 57 | // Creates a new instance of Webhook DTO from models.Webhook 58 | func NewWebhookDTO(w models.Webhook) Webhook { 59 | dto := Webhook{ 60 | ID: w.ID, 61 | Title: w.Title, 62 | ResponseCode: w.ResponseCode, 63 | ResponseDelay: w.ResponseDelay, 64 | ContentType: *w.ContentType, 65 | Payload: *w.Payload, 66 | UserID: w.UserID, 67 | CreatedAt: w.CreatedAt, 68 | UpdatedAt: w.UpdatedAt, 69 | NotifyOnEvent: w.NotifyOnEvent, 70 | Requests: w.Requests, 71 | } 72 | 73 | return dto 74 | } 75 | -------------------------------------------------------------------------------- /internal/handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "html/template" 5 | "log" 6 | "net/http" 7 | "os" 8 | "strings" 9 | "webhook-tester/internal/metrics" 10 | "webhook-tester/internal/service" 11 | "webhook-tester/internal/utils" 12 | 13 | "github.com/gorilla/csrf" 14 | ) 15 | 16 | type RegisterPageData struct { 17 | CSRFField template.HTML 18 | Error string 19 | FullName string 20 | Email string 21 | Password string 22 | } 23 | 24 | type LoginPageData struct { 25 | CSRFField template.HTML 26 | Error string 27 | } 28 | 29 | type ForgotPasswordPageData struct { 30 | CSRFField template.HTML 31 | Error string 32 | Success bool 33 | } 34 | 35 | type ResetPasswordPageData struct { 36 | CSRFField template.HTML 37 | Error string 38 | Token string 39 | Password string 40 | ConfirmPassword string 41 | } 42 | 43 | // AuthHandler handles registration and login 44 | type AuthHandler struct { 45 | auth *service.AuthService 46 | metrics metrics.Recorder 47 | logger *log.Logger 48 | } 49 | 50 | func NewAuthHandler(auth *service.AuthService, l *log.Logger, m metrics.Recorder) *AuthHandler { 51 | return &AuthHandler{auth: auth, logger: l, metrics: m} 52 | } 53 | 54 | func (h *AuthHandler) RegisterGet(w http.ResponseWriter, r *http.Request) { 55 | data := RegisterPageData{ 56 | CSRFField: csrf.TemplateField(r), 57 | } 58 | 59 | utils.RenderHtmlWithoutLayout(w, r, "register", data) 60 | } 61 | 62 | // helper to render the register page 63 | func (h *AuthHandler) renderRegisterForm(w http.ResponseWriter, r *http.Request, data *RegisterPageData) { 64 | data.CSRFField = csrf.TemplateField(r) 65 | utils.RenderHtmlWithoutLayout(w, r, "register", data) 66 | } 67 | 68 | func (h *AuthHandler) RegisterPost(w http.ResponseWriter, r *http.Request) { 69 | if err := r.ParseForm(); err != nil { 70 | http.Error(w, "failed to parse form", http.StatusInternalServerError) 71 | return 72 | } 73 | 74 | fullName := r.FormValue("name") 75 | email := r.FormValue("email") 76 | password := r.FormValue("password") 77 | 78 | rules := utils.PasswordRules{ 79 | MinLength: 8, 80 | RequireLowercase: true, 81 | RequireUppercase: true, 82 | RequireNumber: true, 83 | } 84 | if err := utils.ValidatePassword(password, rules); err != nil { 85 | // re-render with error and preserve inputs 86 | h.renderRegisterForm(w, r, &RegisterPageData{ 87 | Error: err.Error(), 88 | FullName: fullName, 89 | Email: email, 90 | CSRFField: csrf.TemplateField(r), 91 | }) 92 | return 93 | } 94 | 95 | _, err := h.auth.Register(email, password, fullName) 96 | if err != nil { 97 | // Duplicate email? 98 | if strings.Contains(err.Error(), "email already taken") { 99 | h.renderRegisterForm(w, r, &RegisterPageData{ 100 | Error: "That email is already registered", 101 | FullName: fullName, 102 | Email: email, 103 | CSRFField: csrf.TemplateField(r), 104 | }) 105 | return 106 | } 107 | // Otherwise: unexpected 108 | h.logger.Printf("error registering user: %v", err) 109 | http.Error(w, "internal error", http.StatusInternalServerError) 110 | return 111 | } 112 | 113 | h.metrics.IncSignUp() 114 | 115 | http.Redirect(w, r, "/login", http.StatusSeeOther) 116 | } 117 | 118 | func (h *AuthHandler) LoginGet(w http.ResponseWriter, r *http.Request) { 119 | data := LoginPageData{ 120 | CSRFField: csrf.TemplateField(r), 121 | } 122 | utils.RenderHtmlWithoutLayout(w, r, "login", data) 123 | } 124 | 125 | func (h *AuthHandler) LoginPost(w http.ResponseWriter, r *http.Request) { 126 | if err := r.ParseForm(); err != nil { 127 | http.Error(w, "unable to parse form", http.StatusInternalServerError) 128 | return 129 | } 130 | email := r.FormValue("email") 131 | password := r.FormValue("password") 132 | 133 | user, err := h.auth.Authenticate(email, password) 134 | if err != nil { 135 | // On failure, re-show login with a generic error 136 | h.logger.Printf("error authenticating user: %v", err) 137 | h.renderLoginForm(w, r, &LoginPageData{ 138 | Error: "Invalid email or password", 139 | }) 140 | return 141 | } 142 | 143 | err = h.auth.CreateSession(w, r, user) 144 | if err != nil { 145 | h.logger.Printf("error creating session: %v", err) 146 | http.Error(w, "unable to save session", http.StatusInternalServerError) 147 | } 148 | 149 | if c, err := r.Cookie(sessionIdName); err == nil { 150 | c.MaxAge = -1 151 | http.SetCookie(w, c) 152 | } 153 | 154 | h.metrics.IncLogin() 155 | 156 | http.Redirect(w, r, "/", http.StatusSeeOther) 157 | } 158 | 159 | // renderLoginForm is a small helper to DRY up template rendering 160 | func (h *AuthHandler) renderLoginForm(w http.ResponseWriter, r *http.Request, data *LoginPageData) { 161 | data.CSRFField = csrf.TemplateField(r) 162 | utils.RenderHtmlWithoutLayout(w, r, "login", data) 163 | } 164 | 165 | func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { 166 | h.auth.ClearSession(w, r) 167 | http.Redirect(w, r, "/", http.StatusSeeOther) 168 | } 169 | 170 | func (h *AuthHandler) ForgotPasswordGet(w http.ResponseWriter, r *http.Request) { 171 | data := ForgotPasswordPageData{ 172 | CSRFField: csrf.TemplateField(r), 173 | } 174 | utils.RenderHtmlWithoutLayout(w, r, "forgot-password", data) 175 | } 176 | 177 | // ForgotPasswordPost handles the form submission. 178 | func (h *AuthHandler) ForgotPasswordPost(w http.ResponseWriter, r *http.Request) { 179 | if err := r.ParseForm(); err != nil { 180 | http.Error(w, "unable to parse form", http.StatusInternalServerError) 181 | return 182 | } 183 | 184 | email := r.FormValue("email") 185 | domain := os.Getenv("DOMAIN") 186 | 187 | link, err := h.auth.ForgotPassword(email, domain) 188 | data := ForgotPasswordPageData{CSRFField: csrf.TemplateField(r)} 189 | if err != nil { 190 | h.logger.Printf("Forgot password error: %v", err) 191 | // render without revealing details 192 | utils.RenderHtmlWithoutLayout(w, r, "forgot-password", data) 193 | return 194 | } 195 | // Log or email the link 196 | h.logger.Printf("Password reset link: %s", link) 197 | data.Success = true 198 | utils.RenderHtmlWithoutLayout(w, r, "forgot-password", data) 199 | } 200 | 201 | func (h *AuthHandler) renderResetForm(w http.ResponseWriter, r *http.Request, data *ResetPasswordPageData) { 202 | utils.RenderHtmlWithoutLayout(w, r, "reset-password", data) 203 | } 204 | 205 | // ResetPasswordGet renders the reset form if the token is valid. 206 | func (h *AuthHandler) ResetPasswordGet(w http.ResponseWriter, r *http.Request) { 207 | token := r.URL.Query().Get("token") 208 | if token == "" { 209 | h.renderResetForm(w, r, &ResetPasswordPageData{ 210 | Error: "Missing token", 211 | CSRFField: csrf.TemplateField(r), 212 | }) 213 | return 214 | } 215 | 216 | if _, err := h.auth.ValidateResetToken(token); err != nil { 217 | h.logger.Printf("invalid reset token: %v", err) 218 | h.renderResetForm(w, r, &ResetPasswordPageData{ 219 | Error: "Invalid or expired reset link", 220 | CSRFField: csrf.TemplateField(r), 221 | }) 222 | return 223 | } 224 | 225 | // Token is good—show the form 226 | h.renderResetForm(w, r, &ResetPasswordPageData{ 227 | Token: token, 228 | CSRFField: csrf.TemplateField(r), 229 | }) 230 | } 231 | func (h *AuthHandler) ResetPasswordPost(w http.ResponseWriter, r *http.Request) { 232 | if err := r.ParseForm(); err != nil { 233 | http.Error(w, "unable to parse form", http.StatusInternalServerError) 234 | return 235 | } 236 | 237 | token := r.FormValue("token") 238 | password := r.FormValue("password") 239 | confirmPassword := r.FormValue("confirm_password") 240 | 241 | data := &ResetPasswordPageData{ 242 | Token: token, 243 | Password: password, 244 | ConfirmPassword: confirmPassword, 245 | CSRFField: csrf.TemplateField(r), 246 | } 247 | 248 | if password != confirmPassword { 249 | data.Error = "Passwords do not match" 250 | h.renderResetForm(w, r, data) 251 | return 252 | } 253 | 254 | if err := h.auth.ResetPassword(token, password); err != nil { 255 | // webhookSvc returns “invalid or expired token” or other msgs 256 | data.Error = err.Error() 257 | h.renderResetForm(w, r, data) 258 | return 259 | } 260 | 261 | http.Redirect(w, r, "/login", http.StatusSeeOther) 262 | } 263 | -------------------------------------------------------------------------------- /internal/handlers/home.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "html/template" 6 | "webhook-tester/internal/metrics" 7 | "webhook-tester/internal/service" 8 | "webhook-tester/internal/utils" 9 | 10 | "github.com/gorilla/csrf" 11 | "log" 12 | "net/http" 13 | "os" 14 | "time" 15 | "webhook-tester/internal/models" 16 | ) 17 | 18 | type HomeHandler struct { 19 | webhookSvc *service.WebhookService 20 | authSvc *service.AuthService 21 | Logger *log.Logger 22 | Metrics metrics.Recorder 23 | } 24 | 25 | func NewHomeHandler( 26 | webhookSvc *service.WebhookService, 27 | authSvc *service.AuthService, 28 | l *log.Logger, 29 | mr metrics.Recorder, 30 | ) *HomeHandler { 31 | return &HomeHandler{ 32 | webhookSvc: webhookSvc, 33 | authSvc: authSvc, 34 | Logger: l, 35 | Metrics: mr, 36 | } 37 | } 38 | 39 | type HomePageData struct { 40 | CSRFField template.HTML 41 | User models.User 42 | Webhooks []models.Webhook 43 | Webhook models.Webhook 44 | ResponseHeaders string 45 | RequestsCount uint 46 | Domain string 47 | Year int 48 | } 49 | 50 | var sessionIdName = "_webhook_tester_guest_session_id" 51 | 52 | func createDefaultWebhook(svc *service.WebhookService, l *log.Logger) (string, error) { 53 | defaultWh := models.Webhook{ 54 | ID: utils.GenerateID(), 55 | Title: "Default Webhook", 56 | ResponseCode: http.StatusOK, 57 | } 58 | 59 | err := svc.CreateWebhook(&defaultWh) 60 | if err != nil { 61 | l.Printf("Error inserting default webhook: %v", err) 62 | return "", err 63 | } 64 | 65 | return defaultWh.ID, nil 66 | } 67 | 68 | func createDefaultWebhookCookie(webhookID string, w http.ResponseWriter) *http.Cookie { 69 | cookie := &http.Cookie{ 70 | Name: sessionIdName, 71 | Value: webhookID, 72 | Path: "/", 73 | HttpOnly: true, 74 | Secure: false, // Set to true in production 75 | MaxAge: 86400 * 2, // 2 days 76 | } 77 | http.SetCookie(w, cookie) 78 | return cookie 79 | } 80 | 81 | func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) { 82 | userID, _ := h.authSvc.Authorize(r) 83 | 84 | // Get or create default webhook via cookie 85 | cookie, err := r.Cookie(sessionIdName) 86 | if err != nil && userID == 0 { 87 | defaultWhID, err := createDefaultWebhook(h.webhookSvc, h.Logger) 88 | if err != nil { 89 | h.Logger.Printf("Error creating default webhook: %v", err) 90 | http.Error(w, "failed to create webhook", http.StatusInternalServerError) 91 | return 92 | } 93 | cookie = createDefaultWebhookCookie(defaultWhID, w) 94 | h.Metrics.IncWebhooksCreated() 95 | } 96 | var webhooks []models.Webhook 97 | var webhook models.Webhook 98 | var activeWebhook models.Webhook 99 | 100 | // Determine active webhook ID 101 | address := r.URL.Query().Get("address") 102 | var webhookID = address 103 | if webhookID == "" && cookie != nil { 104 | webhookID = cookie.Value 105 | } 106 | 107 | if webhookID != "" && userID == 0 { 108 | wrr, err := h.webhookSvc.GetWebhookWithRequests(webhookID) 109 | if err != nil { 110 | log.Printf("failed to get webhook: %v", err) 111 | cookie.MaxAge = -1 112 | http.SetCookie(w, cookie) 113 | http.Redirect(w, r, "/", http.StatusSeeOther) 114 | return 115 | } 116 | webhook = *wrr 117 | webhooks = append(webhooks, webhook) 118 | } else { 119 | // Load user's other webhooks if logged in 120 | webhooks, _ = h.webhookSvc.ListWebhooks(userID) 121 | } 122 | 123 | if address != "" { 124 | aw, err := h.webhookSvc.GetWebhookWithRequests(address) 125 | if err != nil { 126 | log.Printf("failed to get webhook: %v", err) 127 | } 128 | activeWebhook = *aw 129 | } else if len(webhooks) > 0 { 130 | activeWebhook = webhooks[0] 131 | } 132 | 133 | var headersJSON = "" 134 | if activeWebhook.ResponseHeaders != nil { 135 | b, err := json.Marshal(activeWebhook.ResponseHeaders) 136 | if err != nil { 137 | log.Printf("error marshalling response headers: %v", err) 138 | } else { 139 | headersJSON = string(b) 140 | } 141 | } 142 | 143 | user, _ := h.authSvc.GetCurrentUser(r) 144 | 145 | // RenderHtml the home page 146 | data := HomePageData{ 147 | CSRFField: csrf.TemplateField(r), 148 | User: *user, 149 | Webhooks: webhooks, 150 | Webhook: activeWebhook, 151 | ResponseHeaders: headersJSON, 152 | RequestsCount: uint(len(activeWebhook.Requests)), 153 | Domain: os.Getenv("DOMAIN"), 154 | Year: time.Now().Year(), 155 | } 156 | 157 | utils.RenderHtml(w, r, "home", data) 158 | } 159 | -------------------------------------------------------------------------------- /internal/handlers/pages.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | "webhook-tester/internal/utils" 7 | ) 8 | 9 | type LegalHandler struct{} 10 | 11 | func NewLegalHandler() *LegalHandler { 12 | return &LegalHandler{} 13 | } 14 | 15 | func (h *LegalHandler) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 16 | data := struct { 17 | Year int 18 | }{ 19 | Year: time.Now().Year(), 20 | } 21 | utils.RenderHtmlWithoutLayout(w, r, "policy", data) 22 | } 23 | 24 | func (h *LegalHandler) TermsAndConditions(w http.ResponseWriter, r *http.Request) { 25 | data := struct { 26 | Year int 27 | }{ 28 | Year: time.Now().Year(), 29 | } 30 | utils.RenderHtmlWithoutLayout(w, r, "terms", data) 31 | } 32 | -------------------------------------------------------------------------------- /internal/handlers/webhook.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | "webhook-tester/internal/metrics" 16 | "webhook-tester/internal/models" 17 | "webhook-tester/internal/service" 18 | "webhook-tester/internal/utils" 19 | 20 | "github.com/go-chi/chi/v5" 21 | "gorm.io/datatypes" 22 | ) 23 | 24 | type WebhookHandler struct { 25 | webhookSvc *service.WebhookService 26 | authSvc *service.AuthService 27 | logger *log.Logger 28 | metrics metrics.Recorder 29 | } 30 | 31 | func NewWebhookHandler( 32 | webhookSvc *service.WebhookService, 33 | authSvc *service.AuthService, 34 | logger *log.Logger, 35 | metrics metrics.Recorder) *WebhookHandler { 36 | return &WebhookHandler{ 37 | webhookSvc: webhookSvc, 38 | authSvc: authSvc, 39 | logger: logger, 40 | metrics: metrics, 41 | } 42 | } 43 | 44 | func (h *WebhookHandler) Create(w http.ResponseWriter, r *http.Request) { 45 | userID, err := h.authSvc.Authorize(r) 46 | if err != nil { 47 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 48 | return 49 | } 50 | 51 | err = r.ParseForm() 52 | if err != nil { 53 | h.logger.Printf("error parsing form: %v", err) 54 | http.Error(w, err.Error(), http.StatusBadRequest) 55 | return 56 | } 57 | 58 | title := r.FormValue("title") 59 | contentType := r.FormValue("content_type") 60 | responseCode, _ := strconv.Atoi(r.FormValue("response_code")) 61 | if responseCode == 0 { 62 | responseCode = http.StatusOK 63 | } 64 | responseDelay, _ := strconv.Atoi(r.FormValue("response_delay")) // defaults to 0 65 | payload := r.FormValue("payload") 66 | notify := r.FormValue("notify_on_event") == "true" 67 | 68 | headersStr := r.FormValue("response_headers") 69 | var headers datatypes.JSONMap 70 | if headersStr != "" { 71 | err := json.Unmarshal([]byte(headersStr), &headers) 72 | if err != nil { 73 | log.Printf("error parsing json %s", err) 74 | } 75 | } 76 | 77 | webhookID := utils.GenerateID() 78 | wh := models.Webhook{ 79 | ID: webhookID, 80 | UserID: int(userID), 81 | Title: title, 82 | ContentType: &contentType, 83 | ResponseCode: responseCode, 84 | ResponseDelay: uint(responseDelay), 85 | Payload: &payload, 86 | ResponseHeaders: headers, 87 | NotifyOnEvent: notify, 88 | } 89 | 90 | err = h.webhookSvc.CreateWebhook(&wh) 91 | if err != nil { 92 | h.logger.Printf("Error creating webhook: %v", err) 93 | http.Error(w, err.Error(), http.StatusInternalServerError) 94 | return 95 | } 96 | 97 | h.metrics.IncWebhooksCreated() 98 | 99 | http.Redirect(w, r, fmt.Sprintf("/?address=%s", webhookID), http.StatusSeeOther) 100 | } 101 | 102 | func (h *WebhookHandler) DeleteRequests(w http.ResponseWriter, r *http.Request) { 103 | userID, err := h.authSvc.Authorize(r) 104 | 105 | if err != nil { 106 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 107 | } 108 | 109 | webhookID := chi.URLParam(r, "id") 110 | 111 | if webhookID == "" { 112 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 113 | } 114 | 115 | err = h.webhookSvc.DeleteWebhook(webhookID, userID) 116 | 117 | if err != nil { 118 | h.logger.Printf("Error deleting webhook: %v", err) 119 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 120 | } 121 | 122 | http.Redirect(w, r, fmt.Sprintf("/?address=%s", webhookID), http.StatusSeeOther) 123 | } 124 | 125 | func (h *WebhookHandler) DeleteWebhook(w http.ResponseWriter, r *http.Request) { 126 | userID, err := h.authSvc.Authorize(r) 127 | 128 | if err != nil { 129 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 130 | } 131 | 132 | webhookID := chi.URLParam(r, "id") 133 | 134 | if webhookID == "" { 135 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 136 | } 137 | 138 | err = h.webhookSvc.DeleteWebhook(webhookID, userID) 139 | 140 | if err != nil { 141 | h.logger.Printf("Error deleting webhook: %v", err) 142 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 143 | } 144 | 145 | http.Redirect(w, r, "/", http.StatusSeeOther) 146 | } 147 | 148 | func (h *WebhookHandler) UpdateWebhook(w http.ResponseWriter, r *http.Request) { 149 | userID, err := h.authSvc.Authorize(r) 150 | if err != nil { 151 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 152 | return 153 | } 154 | 155 | webhookID := chi.URLParam(r, "id") 156 | if webhookID == "" { 157 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 158 | return 159 | } 160 | 161 | err = r.ParseForm() 162 | if err != nil { 163 | h.logger.Printf("error parsing form: %v", err) 164 | http.Error(w, err.Error(), http.StatusBadRequest) 165 | return 166 | } 167 | 168 | title := r.FormValue("title") 169 | contentType := r.FormValue("content_type") 170 | responseCode, _ := strconv.Atoi(r.FormValue("response_code")) 171 | if responseCode == 0 { 172 | responseCode = http.StatusOK 173 | } 174 | responseDelay, _ := strconv.Atoi(r.FormValue("response_delay")) // defaults to 0 175 | payload := r.FormValue("payload") 176 | notify := r.FormValue("notify_on_event") == "true" 177 | 178 | headersStr := r.FormValue("response_headers") 179 | var headers datatypes.JSONMap 180 | if headersStr != "" { 181 | err := json.Unmarshal([]byte(headersStr), &headers) 182 | if err != nil { 183 | log.Printf("error parsing json %s", err) 184 | } 185 | } 186 | wh, err := h.webhookSvc.GetUserWebhook(webhookID, userID) 187 | if err != nil { 188 | h.logger.Printf("Error getting webhook: %v", err) 189 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 190 | } 191 | 192 | wh.Title = title 193 | wh.ContentType = &contentType 194 | wh.ResponseCode = responseCode 195 | wh.ResponseDelay = uint(responseDelay) 196 | wh.NotifyOnEvent = notify 197 | wh.Payload = &payload 198 | wh.ResponseHeaders = headers 199 | 200 | err = h.webhookSvc.UpdateWebhook(wh) 201 | if err != nil { 202 | h.logger.Printf("Error updating webhook: %v", err) 203 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 204 | } 205 | http.Redirect(w, r, fmt.Sprintf("/?address=%s", webhookID), http.StatusSeeOther) 206 | } 207 | 208 | var webhookStreams = make(map[string][]chan string) 209 | var mu sync.Mutex 210 | 211 | func (h *WebhookHandler) HandleWebhookRequest(w http.ResponseWriter, r *http.Request) { 212 | webhookID := strings.TrimPrefix(r.URL.Path, "/webhooks/") 213 | h.logger.Printf("Handling webhook request for %s", webhookID) 214 | webhook, err := h.webhookSvc.GetWebhook(webhookID) 215 | 216 | if err != nil { 217 | switch { 218 | case errors.Is(err, sql.ErrNoRows): 219 | w.WriteHeader(http.StatusNotFound) 220 | default: 221 | w.WriteHeader(http.StatusInternalServerError) 222 | } 223 | return 224 | } 225 | 226 | // Read body 227 | body, _ := io.ReadAll(r.Body) 228 | defer func(Body io.ReadCloser) { 229 | err := Body.Close() 230 | if err != nil { 231 | log.Printf("error closing body: %s", err) 232 | } 233 | }(r.Body) 234 | 235 | // Convert headers to a map[string]string 236 | headers := datatypes.JSONMap{} 237 | for k, v := range r.Header { 238 | headers[k] = strings.Join(v, ",") 239 | } 240 | 241 | query := datatypes.JSONMap{} 242 | for k, v := range r.URL.Query() { 243 | query[k] = strings.Join(v, ",") 244 | } 245 | 246 | wr := models.WebhookRequest{ 247 | ID: utils.GenerateID(), 248 | WebhookID: webhookID, 249 | Method: r.Method, 250 | Headers: headers, 251 | Query: query, 252 | Body: string(body), 253 | ReceivedAt: time.Now().UTC(), 254 | } 255 | 256 | err = h.webhookSvc.CreateRequest(&wr) 257 | if err != nil { 258 | h.logger.Printf("error creating webhook request: %s", err) 259 | utils.RenderJSON(w, http.StatusInternalServerError, nil) 260 | return 261 | } 262 | h.metrics.IncWebhookRequest(webhookID) 263 | 264 | // Delay response 265 | if webhook.ResponseDelay > 0 { 266 | time.Sleep(time.Duration(webhook.ResponseDelay) * time.Millisecond) 267 | } 268 | 269 | jsonData, _ := json.Marshal(wr) 270 | 271 | mu.Lock() 272 | for _, ch := range webhookStreams[webhookID] { 273 | select { 274 | case ch <- string(jsonData): 275 | default: // drop if blocked 276 | } 277 | } 278 | mu.Unlock() 279 | 280 | // Set custom response headers if defined 281 | if webhook.ResponseHeaders != nil { 282 | for k, v := range webhook.ResponseHeaders { 283 | w.Header().Set(k, fmt.Sprintf("%v", v)) 284 | } 285 | } 286 | 287 | // Return custom response 288 | if webhook.ContentType != nil { 289 | w.Header().Set("Content-Type", *webhook.ContentType) 290 | } else { 291 | // Default to application json if content type is not specified 292 | w.Header().Set("Content-Type", "application/json") 293 | } 294 | 295 | w.WriteHeader(webhook.ResponseCode) 296 | if webhook.Payload != nil { 297 | if _, err := w.Write([]byte(*webhook.Payload)); err != nil { 298 | h.logger.Printf("error writing payload: %s", err) 299 | } 300 | } 301 | } 302 | 303 | func (h *WebhookHandler) StreamWebhookEvents(w http.ResponseWriter, r *http.Request) { 304 | webhookID := chi.URLParam(r, "id") 305 | 306 | // Set headers for SSE 307 | w.Header().Set("Content-Type", "text/event-stream") 308 | w.Header().Set("Cache-Control", "no-cache") 309 | w.Header().Set("Connection", "keep-alive") 310 | 311 | // Create a channel for this client 312 | eventChan := make(chan string) 313 | mu.Lock() 314 | webhookStreams[webhookID] = append(webhookStreams[webhookID], eventChan) 315 | mu.Unlock() 316 | 317 | // Stream new events 318 | flusher, _ := w.(http.Flusher) 319 | for { 320 | select { 321 | case msg := <-eventChan: 322 | _, err := fmt.Fprintf(w, "data: %s\n\n", msg) 323 | if err != nil { 324 | h.logger.Printf("error writing data: %s", err) 325 | return 326 | } 327 | flusher.Flush() 328 | case <-r.Context().Done(): 329 | mu.Lock() 330 | subs := webhookStreams[webhookID] 331 | for i, sub := range subs { 332 | if sub == eventChan { 333 | webhookStreams[webhookID] = append(subs[:i], subs[i+1:]...) 334 | break 335 | } 336 | } 337 | mu.Unlock() 338 | return 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /internal/handlers/webhook_api.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log" 7 | "net/http" 8 | "time" 9 | "webhook-tester/internal/dtos" 10 | "webhook-tester/internal/metrics" 11 | "webhook-tester/internal/middlewares" 12 | "webhook-tester/internal/models" 13 | "webhook-tester/internal/service" 14 | "webhook-tester/internal/utils" 15 | 16 | "gorm.io/gorm" 17 | 18 | "github.com/go-chi/chi/v5" 19 | ) 20 | 21 | type WebhookAiHandler struct { 22 | Service *service.WebhookService 23 | Metrics metrics.Recorder 24 | Logger *log.Logger 25 | } 26 | 27 | func NewWebhookApiHandler(svc *service.WebhookService, m metrics.Recorder, l *log.Logger) *WebhookAiHandler { 28 | return &WebhookAiHandler{Service: svc, Metrics: m, Logger: l} 29 | } 30 | 31 | // CreateWebhookApi Creates a webhook 32 | // @Summary Create a webhook 33 | // @Description Returns the details of the created webhook 34 | // @Tags Webhooks 35 | // @Produce json 36 | // @Security ApiKeyAuth 37 | // @Param webhook body dtos.CreateWebhookRequest true "Webhook body" 38 | // @Success 200 {object} dtos.Webhook 39 | // @Router /webhooks [post] 40 | func (h *WebhookAiHandler) CreateWebhookApi(w http.ResponseWriter, r *http.Request) { 41 | user := middlewares.GetAPIAuthenticatedUser(r) 42 | input := dtos.CreateWebhookRequest{} 43 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 44 | utils.RenderJSON(w, http.StatusBadRequest, err.Error()) 45 | return 46 | } 47 | 48 | code := input.ResponseCode 49 | if code == 0 { 50 | code = 200 51 | } 52 | 53 | webhook := models.Webhook{ 54 | ID: utils.GenerateID(), 55 | Title: input.Title, 56 | ResponseCode: code, 57 | ResponseDelay: input.ResponseDelay, 58 | ContentType: &input.ContentType, 59 | Payload: &input.Payload, 60 | UserID: int(user.ID), 61 | CreatedAt: time.Now().UTC(), 62 | UpdatedAt: time.Now().UTC(), 63 | NotifyOnEvent: input.NotifyOnEvent, 64 | } 65 | 66 | if err := h.Service.CreateWebhook(&webhook); err != nil { 67 | utils.RenderJSON(w, http.StatusInternalServerError, map[string]interface{}{ 68 | "error": err.Error(), 69 | }) 70 | return 71 | } 72 | h.Metrics.IncWebhooksCreated() 73 | dto := dtos.NewWebhookDTO(webhook) 74 | dto.Requests = make([]models.WebhookRequest, 0) 75 | utils.RenderJSON(w, http.StatusCreated, dto) 76 | } 77 | 78 | // ListWebhooksApi Creates a webhook 79 | // @Summary List webhooks 80 | // @Description List webhooks and associated request 81 | // @Tags Webhooks 82 | // @Produce json 83 | // @Security ApiKeyAuth 84 | // @Success 200 {object} []dtos.Webhook 85 | // @Router /webhooks [get] 86 | func (h *WebhookAiHandler) ListWebhooksApi(w http.ResponseWriter, r *http.Request) { 87 | user := middlewares.GetAPIAuthenticatedUser(r) 88 | webhooks, err := h.Service.ListWebhooks(user.ID) 89 | if err != nil { 90 | utils.RenderJSON(w, http.StatusInternalServerError, map[string]string{ 91 | "error": err.Error(), 92 | }) 93 | return 94 | } 95 | 96 | utils.RenderJSON(w, http.StatusOK, webhooks) 97 | } 98 | 99 | // GetWebhookApi Gets a webhook by webhook ID 100 | // @Summary Get webhook by ID 101 | // @Description Get a webhook by ID along with its requests 102 | // @Tags Webhooks 103 | // @Produce json 104 | // @Security ApiKeyAuth 105 | // @Param id path string true "Webhook ID" 106 | // @Success 200 {object} []dtos.Webhook 107 | // @Router /webhooks/{id} [get] 108 | func (h *WebhookAiHandler) GetWebhookApi(w http.ResponseWriter, r *http.Request) { 109 | webhookID := chi.URLParam(r, "id") 110 | user := middlewares.GetAPIAuthenticatedUser(r) 111 | webhook, err := h.Service.GetUserWebhook(webhookID, user.ID) 112 | 113 | if err != nil { 114 | if errors.Is(err, gorm.ErrRecordNotFound) { 115 | utils.RenderJSON(w, http.StatusNotFound, map[string]string{ 116 | "error": "webhook not found", 117 | }) 118 | return 119 | } 120 | 121 | h.Logger.Printf("error getting webhook: %v", err) 122 | 123 | utils.RenderJSON(w, http.StatusInternalServerError, map[string]string{ 124 | "error": err.Error(), 125 | }) 126 | return 127 | } 128 | dto := dtos.NewWebhookDTO(*webhook) 129 | utils.RenderJSON(w, http.StatusOK, dto) 130 | } 131 | 132 | // UpdateWebhookApi Updates a webhook 133 | // @Summary Updates a webhook 134 | // @Description Updates a webhook 135 | // @Tags Webhooks 136 | // @Produce json 137 | // @Security ApiKeyAuth 138 | // @Param id path string true "Webhook ID" 139 | // @Param webhook body dtos.UpdateWebhookRequest true "Updated webhook" 140 | // @Success 200 {object} []dtos.Webhook 141 | // @Router /webhooks/{id} [put] 142 | func (h *WebhookAiHandler) UpdateWebhookApi(w http.ResponseWriter, r *http.Request) { 143 | webhookID := chi.URLParam(r, "id") 144 | user := middlewares.GetAPIAuthenticatedUser(r) 145 | webhook, err := h.Service.GetUserWebhook(webhookID, user.ID) 146 | if err != nil { 147 | utils.RenderJSON(w, http.StatusInternalServerError, map[string]string{ 148 | "error": err.Error(), 149 | }) 150 | return 151 | } 152 | 153 | input := dtos.UpdateWebhookRequest{} 154 | 155 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 156 | utils.RenderJSON(w, http.StatusBadRequest, map[string]string{ 157 | "error": err.Error(), 158 | }) 159 | return 160 | } 161 | 162 | if webhook.Title != input.Title && input.Title != "" { 163 | webhook.Title = input.Title 164 | } 165 | 166 | if webhook.ResponseCode != input.ResponseCode && input.ResponseCode != 0 { 167 | webhook.ResponseCode = input.ResponseCode 168 | } 169 | 170 | if webhook.ResponseDelay != input.ResponseDelay && input.ResponseDelay != 0 { 171 | webhook.ResponseDelay = input.ResponseDelay 172 | } 173 | 174 | if webhook.ContentType != &input.ContentType && input.ContentType != "" { 175 | webhook.ContentType = &input.ContentType 176 | } 177 | 178 | if webhook.Payload != &input.Payload { 179 | webhook.Payload = &input.Payload 180 | } 181 | 182 | if webhook.NotifyOnEvent != input.NotifyOnEvent { 183 | webhook.NotifyOnEvent = input.NotifyOnEvent 184 | } 185 | 186 | webhook.UpdatedAt = time.Now().UTC() 187 | 188 | if err := h.Service.UpdateWebhook(webhook); err != nil { 189 | utils.RenderJSON(w, http.StatusInternalServerError, map[string]string{ 190 | "error": err.Error(), 191 | }) 192 | return 193 | } 194 | 195 | dto := dtos.NewWebhookDTO(*webhook) 196 | utils.RenderJSON(w, http.StatusOK, dto) 197 | } 198 | 199 | // DeleteWebhookApi deletes a webhook 200 | // @Summary Delete a webhook 201 | // @Description Deletes a webhook 202 | // @Tags Webhooks 203 | // @Produce json 204 | // @Security ApiKeyAuth 205 | // @Param id path string true "Webhook ID" 206 | // @Success 204 {string} string "No Content" 207 | // @Failure 500 {object} ErrorResponse 208 | // @Router /webhooks/{id} [delete] 209 | func (h *WebhookAiHandler) DeleteWebhookApi(w http.ResponseWriter, r *http.Request) { 210 | id := chi.URLParam(r, "id") 211 | user := middlewares.GetAPIAuthenticatedUser(r) 212 | if err := h.Service.DeleteWebhook(id, user.ID); err != nil { 213 | utils.RenderJSON(w, http.StatusInternalServerError, map[string]string{ 214 | "error": err.Error(), 215 | }) 216 | return 217 | } 218 | 219 | w.WriteHeader(http.StatusNoContent) 220 | } 221 | -------------------------------------------------------------------------------- /internal/handlers/webhook_requests.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/csrf" 6 | "github.com/wader/gormstore/v2" 7 | "html/template" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "strings" 13 | "time" 14 | "webhook-tester/internal/metrics" 15 | "webhook-tester/internal/models" 16 | "webhook-tester/internal/service" 17 | "webhook-tester/internal/utils" 18 | 19 | "github.com/go-chi/chi/v5" 20 | ) 21 | 22 | type WebhookRequestHandler struct { 23 | reqService *service.WebhookRequestService 24 | authSvc *service.AuthService 25 | metrics *metrics.Recorder 26 | logger *log.Logger 27 | webhookService *service.WebhookService 28 | sessionStore *gormstore.Store 29 | } 30 | 31 | // NewWebhookRequestHandler creates a new handler. 32 | func NewWebhookRequestHandler( 33 | reqSvc *service.WebhookRequestService, 34 | authSvc *service.AuthService, 35 | webhookSvc *service.WebhookService, 36 | metricsRec *metrics.Recorder, 37 | logger *log.Logger, 38 | ) *WebhookRequestHandler { 39 | return &WebhookRequestHandler{reqService: reqSvc, webhookService: webhookSvc, metrics: metricsRec, logger: logger, authSvc: authSvc} 40 | } 41 | 42 | func (h *WebhookRequestHandler) GetRequest(w http.ResponseWriter, r *http.Request) { 43 | // 1) Extract path & query params 44 | reqID := chi.URLParam(r, "id") 45 | address := r.URL.Query().Get("address") 46 | 47 | // 2) Load the webhook and its requests via the service 48 | wh, err := h.webhookService.GetWebhookWithRequests(address) 49 | if err != nil { 50 | h.logger.Printf("failed to load webhook %s: %v", address, err) 51 | http.Error(w, "could not load webhook", http.StatusInternalServerError) 52 | return 53 | } 54 | 55 | // 3) Load the individual request via the service 56 | reqEvent, err := h.reqService.Get(reqID) 57 | if err != nil { 58 | h.logger.Printf("request %s not found: %v", reqID, err) 59 | http.NotFound(w, r) 60 | return 61 | } 62 | 63 | // 4) Build the sidebar list: either the user’s own webhooks, or just the one 64 | user, _ := h.authSvc.GetCurrentUser(r) 65 | var list []models.Webhook 66 | if user.ID != 0 { 67 | if list, err = h.webhookService.ListWebhooks(user.ID); err != nil { 68 | h.logger.Printf("failed to list webhooks for user %d: %v", user.ID, err) 69 | http.Error(w, "could not load your webhooks", http.StatusInternalServerError) 70 | return 71 | } 72 | } else { 73 | list = []models.Webhook{*wh} 74 | } 75 | 76 | // 5) Render 77 | data := struct { 78 | ID string 79 | Year int 80 | User models.User 81 | Webhooks []models.Webhook 82 | Webhook *models.Webhook 83 | Request *models.WebhookRequest 84 | CSRFField template.HTML 85 | }{ 86 | ID: reqID, 87 | Year: time.Now().Year(), 88 | User: *user, 89 | Webhooks: list, 90 | Webhook: wh, 91 | Request: reqEvent, 92 | CSRFField: csrf.TemplateField(r), 93 | } 94 | 95 | utils.RenderHtml(w, r, "request", data) 96 | } 97 | 98 | func (h *WebhookRequestHandler) DeleteRequest(w http.ResponseWriter, r *http.Request) { 99 | requestId := chi.URLParam(r, "id") 100 | 101 | err := h.reqService.Delete(requestId) 102 | 103 | if err != nil { 104 | h.logger.Printf("failed to delete webhook %s: %v", requestId, err) 105 | http.Error(w, "could not delete webhook", http.StatusInternalServerError) 106 | return 107 | } 108 | 109 | referer := r.Referer() 110 | if referer == "" { 111 | referer = "/" // fallback 112 | } 113 | 114 | // Redirect back to the referring page 115 | http.Redirect(w, r, referer, http.StatusFound) 116 | } 117 | 118 | // ReplayRequest re‐sends a stored webhook request via your services. 119 | func (h *WebhookRequestHandler) ReplayRequest(w http.ResponseWriter, r *http.Request) { 120 | id := chi.URLParam(r, "id") 121 | 122 | reqEvent, err := h.reqService.Get(id) 123 | if err != nil { 124 | h.logger.Printf("replay: request %s not found: %v", id, err) 125 | http.Error(w, "request not found", http.StatusNotFound) 126 | return 127 | } 128 | 129 | domain := os.Getenv("DOMAIN") 130 | target, err := url.JoinPath(domain, "webhooks", reqEvent.WebhookID) 131 | if err != nil { 132 | h.logger.Printf("replay: invalid target URL: %v", err) 133 | http.Error(w, "could not construct replay URL", http.StatusInternalServerError) 134 | return 135 | } 136 | 137 | parsed, _ := url.Parse(target) 138 | q := parsed.Query() 139 | for k, v := range reqEvent.Query { 140 | if s, ok := v.(string); ok { 141 | q.Set(k, s) 142 | } 143 | } 144 | parsed.RawQuery = q.Encode() 145 | 146 | bodyReader := strings.NewReader(reqEvent.Body) 147 | outReq, err := http.NewRequest(reqEvent.Method, parsed.String(), bodyReader) 148 | if err != nil { 149 | h.logger.Printf("replay: error creating HTTP request: %v", err) 150 | http.Error(w, "error creating request", http.StatusInternalServerError) 151 | return 152 | } 153 | for k, v := range reqEvent.Headers { 154 | if s, ok := v.(string); ok { 155 | outReq.Header.Set(k, s) 156 | } 157 | } 158 | 159 | client := &http.Client{Timeout: 10 * time.Second} 160 | resp, err := client.Do(outReq) 161 | if err != nil { 162 | h.logger.Printf("replay: error sending request: %v", err) 163 | http.Error(w, "error sending request", http.StatusBadGateway) 164 | return 165 | } 166 | defer resp.Body.Close() 167 | 168 | // 6) Redirect back to the request details page 169 | redirectURL := fmt.Sprintf("/requests/%s?address=%s", reqEvent.ID, reqEvent.WebhookID) 170 | http.Redirect(w, r, redirectURL, http.StatusSeeOther) 171 | } 172 | -------------------------------------------------------------------------------- /internal/metrics/interface.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | type Recorder interface { 4 | IncWebhooksCreated() 5 | IncWebhookRequest(webhookID string) 6 | IncSignUp() 7 | IncLogin() 8 | } 9 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "github.com/prometheus/client_golang/prometheus" 4 | 5 | var ( 6 | // webhooks 7 | WebhooksCreated = prometheus.NewCounter(prometheus.CounterOpts{ 8 | Name: "webhooks_created_total", 9 | Help: "Total number of webhooks created by users or guests.", 10 | }) 11 | 12 | // incoming webhook requests per webhook ID 13 | WebhookRequestsReceived = prometheus.NewCounterVec(prometheus.CounterOpts{ 14 | Name: "webhook_requests_received_total", 15 | Help: "Total number of webhook requests received per webhook ID.", 16 | }, []string{"webhook_id"}) 17 | 18 | // user signups 19 | SignupsTotal = prometheus.NewCounter( 20 | prometheus.CounterOpts{ 21 | Name: "user_signups_total", 22 | Help: "Total number of successful user registrations.", 23 | }, 24 | ) 25 | 26 | // user logins 27 | LoginsTotal = prometheus.NewCounter( 28 | prometheus.CounterOpts{ 29 | Name: "user_logins_total", 30 | Help: "Total number of successful user logins.", 31 | }, 32 | ) 33 | ) 34 | 35 | // Register prometheus metrics 36 | func Register() { 37 | prometheus.MustRegister( 38 | WebhooksCreated, 39 | WebhookRequestsReceived, 40 | SignupsTotal, 41 | LoginsTotal, 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /internal/metrics/prometheus_recorder.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | type PrometheusRecorder struct{} 4 | 5 | func (r *PrometheusRecorder) IncWebhooksCreated() { 6 | WebhooksCreated.Inc() 7 | } 8 | 9 | func (r *PrometheusRecorder) IncWebhookRequest(webhookID string) { 10 | WebhookRequestsReceived.WithLabelValues(webhookID).Inc() 11 | } 12 | 13 | func (r *PrometheusRecorder) IncSignUp() { 14 | SignupsTotal.Inc() 15 | } 16 | 17 | func (r *PrometheusRecorder) IncLogin() { 18 | LoginsTotal.Inc() 19 | } 20 | -------------------------------------------------------------------------------- /internal/middlewares/middleware.go: -------------------------------------------------------------------------------- 1 | // internal/middlewares/api.go 2 | package middlewares 3 | 4 | import ( 5 | "context" 6 | "net/http" 7 | 8 | "webhook-tester/internal/models" 9 | "webhook-tester/internal/service" 10 | ) 11 | 12 | type ctxKeyUser struct{} 13 | 14 | func RequireAPIKey(auth *service.AuthService) func(http.Handler) http.Handler { 15 | return func(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | apiKey := r.Header.Get("X-API-Key") 18 | if apiKey == "" { 19 | http.Error(w, "API key missing", http.StatusUnauthorized) 20 | return 21 | } 22 | 23 | user, err := auth.ValidateAPIKey(apiKey) 24 | if err != nil { 25 | http.Error(w, "Invalid API key", http.StatusUnauthorized) 26 | return 27 | } 28 | 29 | // attach the full user object to context 30 | ctx := context.WithValue(r.Context(), ctxKeyUser{}, user) 31 | next.ServeHTTP(w, r.WithContext(ctx)) 32 | }) 33 | } 34 | } 35 | 36 | // GetAPIAuthenticatedUser retrieves the user set by RequireAPIKey 37 | func GetAPIAuthenticatedUser(r *http.Request) *models.User { 38 | user, _ := r.Context().Value(ctxKeyUser{}).(*models.User) 39 | return user 40 | } 41 | -------------------------------------------------------------------------------- /internal/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | "time" 6 | ) 7 | 8 | type User struct { 9 | gorm.Model 10 | FullName string `json:"full_name"` 11 | Email string `json:"email" gorm:"type:varchar(255);unique"` 12 | Password string `json:"-"` 13 | APIKey string `json:"-"` 14 | ResetToken string `json:"-"` 15 | ResetTokenExpiry time.Time `json:"-"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/models/webhook.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/datatypes" 7 | ) 8 | 9 | // swagger:model [Webhook] 10 | type Webhook struct { 11 | ID string `gorm:"primaryKey" json:"id"` 12 | Title string `json:"title"` 13 | ResponseCode int `json:"response_code"` 14 | ResponseDelay uint `json:"response_delay"` // milliseconds 15 | ContentType *string `json:"content_type"` 16 | Payload *string `json:"payload"` 17 | ResponseHeaders datatypes.JSONMap `json:"response_headers"` 18 | NotifyOnEvent bool `json:"notify_on_event"` 19 | UserID int `json:"user_id"` 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at,omitempty"` 22 | 23 | Requests []WebhookRequest `gorm:"foreignKey:WebhookID" json:"requests,omitempty"` 24 | } 25 | -------------------------------------------------------------------------------- /internal/models/webhook_request.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/datatypes" 7 | ) 8 | 9 | // swagger:model WebhookRequest 10 | type WebhookRequest struct { 11 | ID string `gorm:"primaryKey" json:"id"` 12 | WebhookID string `json:"webhook_id"` 13 | Method string `json:"method"` 14 | Headers datatypes.JSONMap `json:"headers"` 15 | Query datatypes.JSONMap `json:"query"` 16 | Body string `json:"body"` 17 | ReceivedAt time.Time `json:"received_at"` 18 | } // @name WebhookRequest 19 | -------------------------------------------------------------------------------- /internal/repository/user.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "webhook-tester/internal/models" 4 | 5 | type UserRepository interface { 6 | // Create Creates a new user record 7 | Create(user *models.User) error 8 | // GetByID Finds a user by email 9 | GetByID(id uint) (*models.User, error) 10 | // GetByEmail Get a user by email 11 | GetByEmail(email string) (*models.User, error) 12 | // GetByResetToken looks up a user whose ResetToken matches the given string. 13 | GetByResetToken(token string) (*models.User, error) 14 | // Update existing users 15 | Update(user *models.User) error 16 | // GetByAPIKey looks up a user by their API key. 17 | GetByAPIKey(key string) (*models.User, error) 18 | } 19 | -------------------------------------------------------------------------------- /internal/repository/webhook.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "time" 5 | "webhook-tester/internal/models" 6 | ) 7 | 8 | // WebhookRepository defines data access behavior for webhooks. 9 | type WebhookRepository interface { 10 | // Insert a new webhook 11 | Insert(webhook *models.Webhook) error 12 | // Get a webhook by ID (public) 13 | Get(id string) (*models.Webhook, error) 14 | // GetByUser Gets a webhook by ID and user (returns error if not owned) 15 | GetByUser(id string, userID uint) (*models.Webhook, error) 16 | // GetAll Retrieves all webhooks (public) 17 | GetAll() ([]models.Webhook, error) 18 | // GetAllByUser Retrieve webhooks for a specific user 19 | GetAllByUser(userID uint) ([]models.Webhook, error) 20 | // Update Updates an existing webhook 21 | Update(webhook *models.Webhook) error 22 | // InsertRequest Inserts request for a webhook 23 | InsertRequest(wr *models.WebhookRequest) error 24 | // Delete a webhook and its requests, ensuring ownership if userID > 0 25 | Delete(id string, userID uint) error 26 | // GetWithRequests Get a webhook with its requests, ordered newest first 27 | GetWithRequests(id string) (*models.Webhook, error) 28 | // CleanPublic Clean up public webhooks older than duration d 29 | CleanPublic(d time.Duration) error 30 | } 31 | -------------------------------------------------------------------------------- /internal/repository/webhook_request.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "webhook-tester/internal/models" 4 | 5 | type WebhookRequestRepository interface { 6 | // Insert a new request record 7 | Insert(req *models.WebhookRequest) error 8 | // GetByID retrieves one request by its ID 9 | GetByID(id string) (*models.WebhookRequest, error) 10 | // ListByWebhook returns all requests for a given webhook 11 | ListByWebhook(webhookID string) ([]models.WebhookRequest, error) 12 | // DeleteByID removes one request 13 | DeleteByID(id string) error 14 | // DeleteByWebhook removes all requests for a webhook 15 | DeleteByWebhook(webhookID string) error 16 | } 17 | -------------------------------------------------------------------------------- /internal/routers/api.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "webhook-tester/internal/handlers" 7 | "webhook-tester/internal/metrics" 8 | "webhook-tester/internal/middlewares" 9 | "webhook-tester/internal/service" 10 | 11 | "github.com/go-chi/chi/v5" 12 | ) 13 | 14 | func NewApiRouter(webhookSvc *service.WebhookService, authSvc *service.AuthService, l *log.Logger, metricsRec metrics.Recorder) http.Handler { 15 | r := chi.NewRouter() 16 | 17 | h := handlers.NewWebhookApiHandler(webhookSvc, metricsRec, l) 18 | 19 | r.Route("/webhooks", func(r chi.Router) { 20 | r.Use(middlewares.RequireAPIKey(authSvc)) 21 | r.Get("/", h.ListWebhooksApi) 22 | r.Post("/", h.CreateWebhookApi) 23 | 24 | r.Route("/{id}", func(r chi.Router) { 25 | r.Get("/", h.GetWebhookApi) 26 | r.Put("/", h.UpdateWebhookApi) 27 | r.Delete("/", h.DeleteWebhookApi) 28 | }) 29 | }) 30 | 31 | return r 32 | } 33 | -------------------------------------------------------------------------------- /internal/routers/web.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "strings" 8 | "webhook-tester/internal/handlers" 9 | "webhook-tester/internal/metrics" 10 | "webhook-tester/internal/service" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "github.com/gorilla/csrf" 14 | ) 15 | 16 | func NewWebRouter( 17 | wrs *service.WebhookRequestService, 18 | ws *service.WebhookService, 19 | authSvc *service.AuthService, 20 | metricsRec metrics.Recorder, 21 | logger *log.Logger, 22 | ) http.Handler { 23 | r := chi.NewRouter() 24 | 25 | // CSRF Setup 26 | csrfKey := []byte(os.Getenv("AUTH_SECRET")) 27 | isProd := strings.Contains(os.Getenv("ENV"), "prod") 28 | var trustedOrigins []string 29 | if !isProd { 30 | trustedOrigins = append(trustedOrigins, "localhost:3000") 31 | } 32 | 33 | csrfMiddleware := csrf.Protect( 34 | csrfKey, 35 | csrf.Secure(isProd), 36 | csrf.Path("/"), 37 | csrf.TrustedOrigins(trustedOrigins), 38 | ) 39 | 40 | r.Use(csrfMiddleware) 41 | 42 | webhookReqHandler := handlers.NewWebhookRequestHandler(wrs, authSvc, ws, &metricsRec, logger) 43 | r.Route("/requests", func(r chi.Router) { 44 | r.Get("/{id}", webhookReqHandler.GetRequest) 45 | r.Post("/{id}/delete", webhookReqHandler.DeleteRequest) 46 | r.Post("/{id}/replay", webhookReqHandler.ReplayRequest) 47 | }) 48 | 49 | hh := handlers.NewHomeHandler(ws, authSvc, logger, metricsRec) 50 | r.Get("/", hh.Home) 51 | 52 | webhookHandler := handlers.NewWebhookHandler(ws, authSvc, logger, metricsRec) 53 | r.Post("/create-webhook", webhookHandler.Create) 54 | r.Post("/delete-requests/{id}", webhookHandler.DeleteRequests) 55 | r.Post("/delete-webhook/{id}", webhookHandler.DeleteWebhook) 56 | r.Post("/update-webhook/{id}", webhookHandler.UpdateWebhook) 57 | r.Get("/webhook-stream/{id}", webhookHandler.StreamWebhookEvents) 58 | 59 | authHandler := handlers.NewAuthHandler(authSvc, logger, metricsRec) 60 | r.Get("/register", authHandler.RegisterGet) 61 | r.Post("/register", authHandler.RegisterPost) 62 | r.Get("/login", authHandler.LoginGet) 63 | r.Post("/login", authHandler.LoginPost) 64 | r.Get("/logout", authHandler.Logout) 65 | r.Get("/forgot-password", authHandler.ForgotPasswordGet) 66 | r.Post("/forgot-password", authHandler.ForgotPasswordPost) 67 | r.Get("/reset-password", authHandler.ResetPasswordGet) 68 | r.Post("/reset-password", authHandler.ResetPasswordPost) 69 | 70 | lh := handlers.NewLegalHandler() 71 | r.Get("/privacy", lh.PrivacyPolicy) 72 | r.Get("/terms", lh.TermsAndConditions) 73 | 74 | return r 75 | } 76 | -------------------------------------------------------------------------------- /internal/routers/webhook.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "log" 6 | "net/http" 7 | "webhook-tester/internal/handlers" 8 | "webhook-tester/internal/metrics" 9 | "webhook-tester/internal/service" 10 | ) 11 | 12 | func NewWebhookRouter( 13 | webhookSvc *service.WebhookService, 14 | authSvc *service.AuthService, 15 | logger *log.Logger, 16 | metrics metrics.Recorder, 17 | ) http.Handler { 18 | r := chi.NewRouter() 19 | wh := handlers.NewWebhookHandler(webhookSvc, authSvc, logger, metrics) 20 | 21 | // Match all HTTP methods at /{webhookID} 22 | r.HandleFunc("/*", wh.HandleWebhookRequest) 23 | return r 24 | } 25 | -------------------------------------------------------------------------------- /internal/service/auth.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/wader/gormstore/v2" 7 | "gorm.io/gorm" 8 | "net/http" 9 | "os" 10 | "time" 11 | "webhook-tester/internal/models" 12 | "webhook-tester/internal/repository" 13 | "webhook-tester/internal/utils" 14 | ) 15 | 16 | // AuthService holds user business logic 17 | type AuthService struct { 18 | repo repository.UserRepository 19 | sessionStore *gormstore.Store 20 | } 21 | 22 | // NewAuthService creates an AuthService 23 | func NewAuthService(userRepo repository.UserRepository, db *gorm.DB, authSecret string) *AuthService { 24 | // build the GORM‐backed session store 25 | store := gormstore.New(db, []byte(authSecret)) 26 | quit := make(chan struct{}) 27 | go store.PeriodicCleanup(48*time.Hour, quit) 28 | 29 | return &AuthService{ 30 | repo: userRepo, 31 | sessionStore: store, 32 | } 33 | } 34 | 35 | // Register creates a new user with hashed password 36 | func (s *AuthService) Register(email, plainPassword, fullName string) (*models.User, error) { 37 | if _, err := s.repo.GetByEmail(email); err == nil { 38 | return nil, fmt.Errorf("email already taken") 39 | } 40 | hash, err := utils.HashPassword(plainPassword) 41 | if err != nil { 42 | return nil, err 43 | } 44 | key, err := utils.GenerateAPIKey("user_", 32) 45 | if err != nil { 46 | return nil, err 47 | } 48 | user := &models.User{FullName: fullName, Email: email, Password: hash, APIKey: key} 49 | if err := s.repo.Create(user); err != nil { 50 | return nil, err 51 | } 52 | return user, nil 53 | } 54 | 55 | // Authenticate verifies credentials 56 | func (s *AuthService) Authenticate(email, plainPassword string) (*models.User, error) { 57 | user, err := s.repo.GetByEmail(email) 58 | if err != nil { 59 | return nil, errors.New("invalid credentials") 60 | } 61 | if !utils.CheckPasswordHash(plainPassword, user.Password) { 62 | return nil, errors.New("invalid credentials") 63 | } 64 | return user, nil 65 | } 66 | 67 | // Authorize extracts and validates the user_id from the session cookie. 68 | func (s *AuthService) Authorize(r *http.Request) (uint, error) { 69 | const Name = "_webhook_tester_session_id" 70 | authErr := errors.New("unauthorized") 71 | sess, err := s.sessionStore.Get(r, Name) 72 | if err != nil { 73 | return 0, authErr 74 | } 75 | raw, ok := sess.Values["user_id"] 76 | uid, ok2 := raw.(uint) 77 | if !ok || !ok2 { 78 | return 0, authErr 79 | } 80 | return uid, nil 81 | } 82 | 83 | // GetCurrentUser pulls the session and looks up the full User record. 84 | func (s *AuthService) GetCurrentUser(r *http.Request) (*models.User, error) { 85 | userID, err := s.Authorize(r) 86 | if err != nil { 87 | return nil, err 88 | } 89 | return s.repo.GetByID(userID) 90 | } 91 | 92 | // CreateSession establishes a new session cookie for the given user. 93 | func (s *AuthService) CreateSession(w http.ResponseWriter, r *http.Request, user *models.User) error { 94 | const Name = "_webhook_tester_session_id" 95 | sess, err := s.sessionStore.Get(r, Name) 96 | if err != nil { 97 | // if there was no existing session, we still want a brand‐new one 98 | sess, _ = s.sessionStore.New(r, Name) 99 | } 100 | sess.Values["user_id"] = user.ID 101 | sess.Options.MaxAge = 86400 * 2 // two days 102 | sess.Options.HttpOnly = true 103 | sess.Options.Secure = os.Getenv("ENV") == "prod" 104 | return s.sessionStore.Save(r, w, sess) 105 | } 106 | 107 | // ClearSession invalidates the current session cookie. 108 | func (s *AuthService) ClearSession(w http.ResponseWriter, r *http.Request) { 109 | const Name = "_webhook_tester_session_id" 110 | if sess, err := s.sessionStore.Get(r, Name); err == nil { 111 | sess.Options.MaxAge = -1 112 | _ = s.sessionStore.Save(r, w, sess) 113 | } 114 | } 115 | 116 | // ForgotPassword generates a reset token, sets expiry, and returns the token 117 | func (s *AuthService) ForgotPassword(email, domain string) (string, error) { 118 | user, err := s.repo.GetByEmail(email) 119 | if err != nil { 120 | return "", fmt.Errorf("user not found") 121 | } 122 | // Generate secure token 123 | token, err := utils.GenerateSecureToken(32) 124 | if err != nil { 125 | return "", err 126 | } 127 | user.ResetToken = token 128 | user.ResetTokenExpiry = time.Now().Add(24 * time.Hour) 129 | if err := s.repo.Update(user); err != nil { 130 | return "", err 131 | } 132 | return fmt.Sprintf("%s/reset-password?token=%s", domain, token), nil 133 | } 134 | 135 | // ValidateResetToken looks up the user by token and ensures it hasn't expired. 136 | func (s *AuthService) ValidateResetToken(token string) (*models.User, error) { 137 | user, err := s.repo.GetByResetToken(token) 138 | if err != nil { 139 | return nil, fmt.Errorf("invalid or expired token") 140 | } 141 | if time.Now().After(user.ResetTokenExpiry) { 142 | return nil, fmt.Errorf("invalid or expired token") 143 | } 144 | return user, nil 145 | } 146 | 147 | // ResetPassword validates the token, enforces password rules, hashes, 148 | // and then persists the new password. 149 | func (s *AuthService) ResetPassword(token, newPassword string) error { 150 | user, err := s.repo.GetByResetToken(token) 151 | if err != nil { 152 | return fmt.Errorf("invalid or expired reset link") 153 | } 154 | if time.Now().After(user.ResetTokenExpiry) { 155 | return fmt.Errorf("invalid or expired reset link") 156 | } 157 | 158 | rules := utils.PasswordRules{ 159 | MinLength: 8, 160 | RequireLowercase: true, 161 | RequireUppercase: true, 162 | RequireNumber: true, 163 | } 164 | if err := utils.ValidatePassword(newPassword, rules); err != nil { 165 | return err 166 | } 167 | 168 | hash, err := utils.HashPassword(newPassword) 169 | if err != nil { 170 | return err 171 | } 172 | user.Password = hash 173 | user.ResetToken = "" 174 | user.ResetTokenExpiry = time.Time{} 175 | 176 | return s.repo.Update(user) 177 | } 178 | 179 | func (s *AuthService) ValidateAPIKey(key string) (*models.User, error) { 180 | user, err := s.repo.GetByAPIKey(key) 181 | if err != nil { 182 | return nil, fmt.Errorf("invalid API key") 183 | } 184 | return user, nil 185 | } 186 | -------------------------------------------------------------------------------- /internal/service/webhook.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "time" 5 | "webhook-tester/internal/models" 6 | "webhook-tester/internal/repository" 7 | ) 8 | 9 | // WebhookService encapsulates business logic for webhooks. 10 | type WebhookService struct { 11 | repo repository.WebhookRepository 12 | } 13 | 14 | // NewWebhookService constructs a WebhookService with the given repository. 15 | func NewWebhookService(repo repository.WebhookRepository) *WebhookService { 16 | return &WebhookService{repo: repo} 17 | } 18 | 19 | // CreateWebhook creates a new webhook record. 20 | func (s *WebhookService) CreateWebhook(w *models.Webhook) error { 21 | // e.g., generate ID, validate 22 | return s.repo.Insert(w) 23 | } 24 | 25 | // GetWebhook retrieves a public webhook by ID. 26 | func (s *WebhookService) GetWebhook(id string) (*models.Webhook, error) { 27 | return s.repo.Get(id) 28 | } 29 | 30 | // GetUserWebhook retrieves a webhook by ID for a specific user. 31 | func (s *WebhookService) GetUserWebhook(id string, userID uint) (*models.Webhook, error) { 32 | return s.repo.GetByUser(id, userID) 33 | } 34 | 35 | // ListWebhooks lists public or user-specific webhooks. 36 | func (s *WebhookService) ListWebhooks(userID uint) ([]models.Webhook, error) { 37 | if userID == 0 { 38 | return s.repo.GetAll() 39 | } 40 | return s.repo.GetAllByUser(userID) 41 | } 42 | 43 | // UpdateWebhook updates an existing webhook. 44 | func (s *WebhookService) UpdateWebhook(w *models.Webhook) error { 45 | return s.repo.Update(w) 46 | } 47 | 48 | func (s *WebhookService) CreateRequest(wr *models.WebhookRequest) error { 49 | return s.repo.InsertRequest(wr) 50 | } 51 | 52 | // DeleteWebhook deletes a webhook and its requests. 53 | func (s *WebhookService) DeleteWebhook(id string, userID uint) error { 54 | return s.repo.Delete(id, userID) 55 | } 56 | 57 | // GetWebhookWithRequests fetches a webhook along with its requests. 58 | func (s *WebhookService) GetWebhookWithRequests(id string) (*models.Webhook, error) { 59 | return s.repo.GetWithRequests(id) 60 | } 61 | 62 | // CleanPublicWebhooks cleans up old public webhooks. 63 | func (s *WebhookService) CleanPublicWebhooks(d time.Duration) error { 64 | return s.repo.CleanPublic(d) 65 | } 66 | -------------------------------------------------------------------------------- /internal/service/webhook_request.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "webhook-tester/internal/models" 5 | "webhook-tester/internal/repository" 6 | ) 7 | 8 | // WebhookRequestService encapsulates business logic for webhook events. 9 | type WebhookRequestService struct { 10 | repo repository.WebhookRequestRepository 11 | } 12 | 13 | // NewWebhookRequestService constructs a WebhookRequestService. 14 | func NewWebhookRequestService(repo repository.WebhookRequestRepository) *WebhookRequestService { 15 | return &WebhookRequestService{repo: repo} 16 | } 17 | 18 | // Record records a new webhook request event. 19 | func (s *WebhookRequestService) Record(rq *models.WebhookRequest) error { 20 | return s.repo.Insert(rq) 21 | } 22 | 23 | // Get retrieves a single request by ID. 24 | func (s *WebhookRequestService) Get(id string) (*models.WebhookRequest, error) { 25 | return s.repo.GetByID(id) 26 | } 27 | 28 | // List returns recent requests for a webhook. 29 | func (s *WebhookRequestService) List(webhookID string) ([]models.WebhookRequest, error) { 30 | return s.repo.ListByWebhook(webhookID) 31 | } 32 | 33 | // Delete removes a single request. 34 | func (s *WebhookRequestService) Delete(id string) error { 35 | return s.repo.DeleteByID(id) 36 | } 37 | 38 | // DeleteAll removes all requests for a webhook. 39 | func (s *WebhookRequestService) DeleteAll(webhookID string) error { 40 | return s.repo.DeleteByWebhook(webhookID) 41 | } 42 | -------------------------------------------------------------------------------- /internal/store/user.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "errors" 5 | "gorm.io/gorm" 6 | "log" 7 | "webhook-tester/internal/models" 8 | "webhook-tester/internal/repository" 9 | ) 10 | 11 | var _ repository.UserRepository = &GormUserRepo{} 12 | 13 | type GormUserRepo struct { 14 | DB *gorm.DB 15 | logger *log.Logger 16 | } 17 | 18 | func NewGormUserRepo(db *gorm.DB, l *log.Logger) *GormUserRepo { 19 | return &GormUserRepo{DB: db, logger: l} 20 | } 21 | 22 | func (r *GormUserRepo) Create(user *models.User) error { 23 | if err := r.DB.Create(user).Error; err != nil { 24 | r.logger.Printf("failed to create user: %v", err) 25 | return err 26 | } 27 | return nil 28 | } 29 | 30 | func (r *GormUserRepo) GetByEmail(email string) (*models.User, error) { 31 | var u models.User 32 | err := r.DB.First(&u, "email = ?", email).Error 33 | if errors.Is(err, gorm.ErrRecordNotFound) { 34 | return nil, err 35 | } 36 | if err != nil { 37 | r.logger.Printf("failed to get user by email: %v", err) 38 | } 39 | return &u, err 40 | } 41 | 42 | func (r *GormUserRepo) GetByID(id uint) (*models.User, error) { 43 | var u models.User 44 | err := r.DB.First(&u, "id = ?", id).Error 45 | if errors.Is(err, gorm.ErrRecordNotFound) { 46 | return nil, err 47 | } 48 | if err != nil { 49 | r.logger.Printf("failed to get user by id: %v", err) 50 | } 51 | return &u, err 52 | } 53 | 54 | // GetByResetToken looks up a user whose ResetToken matches the given string. 55 | func (r *GormUserRepo) GetByResetToken(token string) (*models.User, error) { 56 | var u models.User 57 | err := r.DB.First(&u, "reset_token = ?", token).Error 58 | if err != nil { 59 | if errors.Is(err, gorm.ErrRecordNotFound) { 60 | r.logger.Printf("reset token not found: %s", token) 61 | } else { 62 | r.logger.Printf("failed to query reset token: %v", err) 63 | } 64 | } 65 | return &u, err 66 | } 67 | 68 | func (r *GormUserRepo) Update(user *models.User) error { 69 | if err := r.DB.Save(user).Error; err != nil { 70 | r.logger.Printf("failed to update user: %v", err) 71 | return err 72 | } 73 | return nil 74 | } 75 | 76 | func (r *GormUserRepo) GetByAPIKey(key string) (*models.User, error) { 77 | var u models.User 78 | err := r.DB.First(&u, "api_key = ?", key).Error 79 | if err != nil { 80 | r.logger.Printf("GetByAPIKey failed: %v", err) 81 | } 82 | return &u, err 83 | } 84 | -------------------------------------------------------------------------------- /internal/store/webhook.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "errors" 5 | "gorm.io/gorm" 6 | "log" 7 | "time" 8 | "webhook-tester/internal/models" 9 | "webhook-tester/internal/repository" 10 | ) 11 | 12 | // Ensure GormWebhookRepo implements repository.WebhookRepository 13 | var _ repository.WebhookRepository = &GormWebhookRepo{} 14 | 15 | type GormWebhookRepo struct { 16 | DB *gorm.DB 17 | logger *log.Logger 18 | } 19 | 20 | func NewGormWebookRepo(db *gorm.DB, l *log.Logger) *GormWebhookRepo { 21 | return &GormWebhookRepo{DB: db, logger: l} 22 | } 23 | 24 | func (r GormWebhookRepo) Insert(webhook *models.Webhook) error { 25 | err := r.DB.Create(&webhook).Error 26 | if err != nil { 27 | r.logger.Printf("failed to create webhook: %v", err) 28 | } 29 | return err 30 | } 31 | 32 | func (r GormWebhookRepo) Get(id string) (*models.Webhook, error) { 33 | var w models.Webhook 34 | err := r.DB.First(&w, "id = ?", id).Error 35 | if err != nil { 36 | r.logger.Printf("failed to get webhook: %v", err) 37 | } 38 | return &w, err 39 | } 40 | 41 | func (r GormWebhookRepo) InsertRequest(w *models.WebhookRequest) error { 42 | return r.DB.Create(&w).Error 43 | } 44 | 45 | func (r GormWebhookRepo) GetByUser(id string, userID uint) (*models.Webhook, error) { 46 | var w models.Webhook 47 | err := r.DB.First(&w, "id = ? AND user_id = ?", id, userID).Error 48 | if err != nil { 49 | r.logger.Printf("failed to get webhook: %v", err) 50 | } 51 | return &w, err 52 | } 53 | 54 | func (r GormWebhookRepo) GetAll() ([]models.Webhook, error) { 55 | var webhooks []models.Webhook 56 | err := r.DB.Model(&models.Webhook{}).Preload("Requests").Find(&webhooks).Error 57 | if err != nil { 58 | r.logger.Printf("failed to get webhooks: %v", err) 59 | } 60 | return webhooks, err 61 | } 62 | 63 | func (r GormWebhookRepo) GetAllByUser(userID uint) ([]models.Webhook, error) { 64 | var webhooks []models.Webhook 65 | err := r.DB.Preload("Requests", func(db *gorm.DB) *gorm.DB { 66 | return db.Order("received_at DESC").Limit(1000) 67 | }). 68 | Where("user_id = ?", userID).Find(&webhooks). 69 | Order("created_at DESC").Error 70 | 71 | if err != nil { 72 | r.logger.Printf("Error loading user webhooks: %v", err) 73 | return webhooks, err 74 | } 75 | return webhooks, nil 76 | } 77 | 78 | func (r GormWebhookRepo) Update(webhook *models.Webhook) error { 79 | err := r.DB.Save(&webhook).Error 80 | if err != nil { 81 | r.logger.Printf("failed to update webhook: %v", err) 82 | } 83 | return err 84 | } 85 | 86 | func (r GormWebhookRepo) Delete(id string, userID uint) error { 87 | return r.DB.Transaction(func(tx *gorm.DB) error { 88 | // Check if webhook exists and belongs to user 89 | var wh models.Webhook 90 | err := tx.First(&wh, "id = ? AND user_id = ?", id, userID).Error 91 | if err != nil { 92 | if errors.Is(err, gorm.ErrRecordNotFound) { 93 | r.logger.Printf("webhook not found or unauthorized: id=%s user_id=%d", id, userID) 94 | } else { 95 | r.logger.Printf("error checking webhook ownership: %v", err) 96 | } 97 | return err 98 | } 99 | 100 | // Delete webhook requests 101 | if err := tx.Delete(&models.WebhookRequest{}, "webhook_id = ?", id).Error; err != nil { 102 | r.logger.Printf("failed to delete webhook requests: %v", err) 103 | return err 104 | } 105 | 106 | // Delete webhook 107 | if err := tx.Delete(&models.Webhook{}, "id = ?", id).Error; err != nil { 108 | r.logger.Printf("failed to delete webhook: %v", err) 109 | return err 110 | } 111 | 112 | return nil 113 | }) 114 | } 115 | 116 | func (r GormWebhookRepo) GetWithRequests(id string) (*models.Webhook, error) { 117 | var webhook models.Webhook 118 | err := r.DB.Preload("Requests", func(db *gorm.DB) *gorm.DB { 119 | return db.Order("received_at DESC") 120 | }).First(&webhook, "id = ?", id).Error 121 | return &webhook, err 122 | } 123 | 124 | // CleanPublic deletes anonymous (public) webhooks and their associated requests 125 | // that were created before a specified duration threshold. 126 | // 127 | // A webhook is considered public if it has no associated user (i.e., user_id = 0). 128 | // This function queries for all such webhooks created earlier than the current time minus `d`, 129 | // then deletes both the webhooks and their related webhook requests within a single transaction. 130 | // 131 | // Parameters: 132 | // - db: a *gorm.DB database connection. 133 | // - d: a time.Duration representing the age threshold (e.g., 72*time.Hour). 134 | // 135 | // This function is useful for cleaning up stale, guest-generated webhooks 136 | // that should not persist indefinitely. 137 | // 138 | // Any error during the transaction is logged but not returned. 139 | func (r GormWebhookRepo) CleanPublic(d time.Duration) error { 140 | r.logger.Println("Cleaning public webhooks") 141 | beforeDate := time.Now().Add(-d).UTC() 142 | 143 | err := r.DB.Transaction(func(tx *gorm.DB) error { 144 | var webhooks []models.Webhook 145 | tx.Where("created_at > ? AND user_id = 0", beforeDate).Find(&webhooks) 146 | 147 | var webhookIDs []string 148 | for _, webhook := range webhooks { 149 | webhookIDs = append(webhookIDs, webhook.ID) 150 | } 151 | 152 | // delete requests 153 | err := tx.Where("webhook_id IN (?)", webhookIDs).Delete(&models.WebhookRequest{}).Error 154 | if err != nil { 155 | r.logger.Printf("Error deleting webhooks: %v", err) 156 | return err 157 | } 158 | 159 | return nil 160 | }) 161 | 162 | if err != nil { 163 | r.logger.Printf("error cleaning public webhooks: %v", err) 164 | } 165 | 166 | return err 167 | } 168 | -------------------------------------------------------------------------------- /internal/store/webhook_request.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | "log" 6 | "webhook-tester/internal/models" 7 | "webhook-tester/internal/repository" 8 | ) 9 | 10 | // Ensure GormWebhookRequestRepo implements repository.WebhookRequestRepository 11 | var _ repository.WebhookRequestRepository = &GormWebhookRequestRepo{} 12 | 13 | // GormWebhookRequestRepo is a GORM implementation of WebhookRequestRepository. 14 | type GormWebhookRequestRepo struct { 15 | DB *gorm.DB 16 | logger *log.Logger 17 | } 18 | 19 | // NewGormWebhookRequestRepo constructs a new repository with a logger. 20 | func NewGormWebhookRequestRepo(db *gorm.DB, logger *log.Logger) *GormWebhookRequestRepo { 21 | return &GormWebhookRequestRepo{DB: db, logger: logger} 22 | } 23 | 24 | func (r *GormWebhookRequestRepo) Insert(req *models.WebhookRequest) error { 25 | if err := r.DB.Create(req).Error; err != nil { 26 | r.logger.Printf("insert request failed: %v", err) 27 | return err 28 | } 29 | return nil 30 | } 31 | 32 | func (r *GormWebhookRequestRepo) GetByID(id string) (*models.WebhookRequest, error) { 33 | var wr models.WebhookRequest 34 | if err := r.DB.First(&wr, "id = ?", id).Error; err != nil { 35 | r.logger.Printf("get request %s failed: %v", id, err) 36 | return nil, err 37 | } 38 | return &wr, nil 39 | } 40 | 41 | func (r *GormWebhookRequestRepo) ListByWebhook(webhookID string) ([]models.WebhookRequest, error) { 42 | var list []models.WebhookRequest 43 | if err := r.DB. 44 | Where("webhook_id = ?", webhookID). 45 | Order("received_at DESC"). 46 | Find(&list).Error; err != nil { 47 | r.logger.Printf("list requests for %s failed: %v", webhookID, err) 48 | return nil, err 49 | } 50 | return list, nil 51 | } 52 | 53 | func (r *GormWebhookRequestRepo) DeleteByID(id string) error { 54 | if err := r.DB.Delete(&models.WebhookRequest{}, "id = ?", id).Error; err != nil { 55 | r.logger.Printf("delete request %s failed: %v", id, err) 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | func (r *GormWebhookRequestRepo) DeleteByWebhook(webhookID string) error { 62 | if err := r.DB. 63 | Where("webhook_id = ?", webhookID). 64 | Delete(&models.WebhookRequest{}).Error; err != nil { 65 | r.logger.Printf("delete all requests for %s failed: %v", webhookID, err) 66 | return err 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/utils/api_key.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // GenerateAPIKey returns an API key with the given prefix. 11 | // Example: "user_" + 64-char hex key → "user_xxxx..." 12 | func GenerateAPIKey(prefix string, length int) (string, error) { 13 | if length < 16 { 14 | return "", fmt.Errorf("API key length must be at least 16 bytes") 15 | } 16 | if strings.Contains(prefix, " ") { 17 | return "", fmt.Errorf("API key prefix must not contain spaces") 18 | } 19 | 20 | bytes := make([]byte, length) 21 | _, err := rand.Read(bytes) 22 | if err != nil { 23 | return "", fmt.Errorf("failed to generate secure random bytes: %w", err) 24 | } 25 | 26 | key := hex.EncodeToString(bytes) 27 | return fmt.Sprintf("%s%s", prefix, key), nil 28 | } 29 | -------------------------------------------------------------------------------- /internal/utils/id.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | gonanoid "github.com/matoous/go-nanoid/v2" 5 | "log" 6 | ) 7 | 8 | func GenerateID() string { 9 | id, err := gonanoid.New() 10 | if err != nil { 11 | log.Print("error creating id: ", err) 12 | } 13 | return id 14 | } 15 | -------------------------------------------------------------------------------- /internal/utils/passwords.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "golang.org/x/crypto/bcrypt" 6 | "regexp" 7 | ) 8 | 9 | type PasswordRules struct { 10 | MinLength int 11 | RequireUppercase bool 12 | RequireLowercase bool 13 | RequireNumber bool 14 | } 15 | 16 | func HashPassword(password string) (string, error) { 17 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 18 | return string(bytes), err 19 | } 20 | 21 | func CheckPasswordHash(password, hash string) bool { 22 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 23 | return err == nil 24 | } 25 | 26 | func ValidatePassword(pw string, rules PasswordRules) error { 27 | if len(pw) < rules.MinLength { 28 | return fmt.Errorf("Password must be at least %d characters", rules.MinLength) 29 | } 30 | 31 | if rules.RequireUppercase && !regexp.MustCompile("[A-Z]").MatchString(pw) { 32 | return fmt.Errorf("Password must contain at least one uppercase letter") 33 | } 34 | 35 | if rules.RequireLowercase && !regexp.MustCompile("[a-z]").MatchString(pw) { 36 | return fmt.Errorf("Password must contain at least one lowercase letter") 37 | } 38 | 39 | if rules.RequireNumber && !regexp.MustCompile("[0-9]").MatchString(pw) { 40 | return fmt.Errorf("Password must contain at least one number") 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/utils/render_html.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | "webhook-tester/internal/web/templates" 7 | ) 8 | 9 | func RenderHtml(w http.ResponseWriter, r *http.Request, tmplName string, data interface{}) { 10 | files := []string{ 11 | "base.html", 12 | "layout.html", 13 | tmplName + ".html", 14 | } 15 | tmpl := template.Must(template.ParseFS(templates.Templates, files...)) 16 | 17 | if err := tmpl.Execute(w, data); err != nil { 18 | http.Error(w, "template error", http.StatusInternalServerError) 19 | } 20 | } 21 | 22 | func RenderHtmlWithoutLayout(w http.ResponseWriter, r *http.Request, tmplName string, data interface{}) { 23 | files := []string{ 24 | "base.html", 25 | tmplName + ".html", 26 | } 27 | tmpl := template.Must(template.ParseFS(templates.Templates, files...)) 28 | 29 | if err := tmpl.Execute(w, data); err != nil { 30 | http.Error(w, "template error", http.StatusInternalServerError) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/utils/render_json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/unrolled/render" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | func RenderJSON(w http.ResponseWriter, status int, payload interface{}) { 10 | w.Header().Set("Content-Type", "application/json") 11 | 12 | if payload == nil { 13 | w.WriteHeader(status) 14 | return 15 | } 16 | 17 | err := render.New().JSON(w, status, payload) 18 | if err != nil { 19 | log.Print("error rendering json: ", err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/utils/util.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | ) 7 | 8 | // GenerateSecureToken returns a secure random token of n bytes, hex-encoded. 9 | func GenerateSecureToken(n int) (string, error) { 10 | b := make([]byte, n) 11 | _, err := rand.Read(b) 12 | if err != nil { 13 | return "", err 14 | } 15 | return hex.EncodeToString(b), nil 16 | } 17 | -------------------------------------------------------------------------------- /internal/web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |11 | Note: Incoming webhook data will be stored temporarily. 12 | Create a free account 15 | to retain request logs and enable advanced features like replay. 16 |
17 |
40 | Use this key in the X-API-Key
header to authenticate your API
41 | requests.
42 |
111 | Use this endpoint to capture any HTTP request. We'll log and display the 112 | details in real-time. 113 |
114 |curl
123 | 137 | curl -X POST {{ .Domain }}/webhooks/{{ .Webhook.ID }} \ 139 | -H "Content-Type: application/json" \ 140 | -d '{"event":"test","status":"success"}' 141 |142 |
191 | {{ $key }} 192 | | 193 |{{ $val }} | 194 |
209 | {{ $key }} 210 | | 211 |{{ $val }} | 212 |
228 | {{ $key }} 229 | | 230 |{{ $val }} | 231 |
261 | Webhook Tester is a lightweight platform that lets developers create 262 | temporary webhook endpoints to inspect and debug HTTP requests in 263 | real-time. Whether you're integrating with Stripe, GitHub, Twilio, or any 264 | custom service, this tool gives you full visibility into the requests your 265 | app is sending or receiving. 266 |
267 |268 | Capture headers, query parameters, request bodies, and more — all without 269 | writing a single line of backend code. Tailor responses, simulate delays, 270 | and replay requests to your own servers. 271 |
272 |10 | Webhook Tester lets you instantly inspect incoming webhooks, view 11 | headers, payloads, and replay them with ease. Perfect for developers 12 | integrating third-party services. 13 |
14 | 18 | 🚀 Get Started Free 19 | 20 |33 | Generate unique webhook endpoints in seconds for testing any service. 34 |
35 |39 | View request headers, body, query parameters, and method — all in 40 | real-time. 41 |
42 |46 | Simulate different responses with configurable status, content type, 47 | and delay. 48 |
49 |53 | Send captured requests to your staging or local server for easy 54 | re-testing. 55 |
56 |60 | See recent webhooks, view details, and manage everything from one 61 | clean UI. 62 |
63 |69 | CSRF protection, API keys, and support for temporary or account-based 70 | sessions. 71 |
72 |81 | We give you a public session-based webhook to get started immediately. 82 | Upgrade by creating an account. 83 |
84 | 88 | Try Public Webhook 89 | 90 |54 | Don't have an account? 55 | Create one 56 |
57 |Effective Date: {{ .Year }}
6 | 7 |13 | Webhook Data (User-Provided): When you send requests to 14 | a webhook URL, we collect: 15 |
16 |23 | This data is collected solely for debugging, inspection, and replay 24 | functionality. 25 | You should not send sensitive personal data or production 27 | secrets. 29 |
30 | 31 |32 | Account Information: If you register an account, we 33 | collect: 34 |
35 |53 | We do not sell or share your data with third parties. 54 |
55 |
62 | Public (unauthenticated) webhook data is
63 | automatically deleted after a short retention period
64 | (e.g., 24–48 hours).
65 | Authenticated users’ data may be retained until deleted by the user or
66 | the account is removed.
67 |
We use cookies and sessions to:
75 |85 | We implement reasonable safeguards to protect webhook and user data. 86 | However, due to the nature of webhooks, 87 | you should never send confidential or production data 88 | through this service. 89 |
90 |You may:
95 |107 | We may update this policy from time to time. Updates will be posted on 108 | this page with a revised effective date. 109 |
110 |115 | For privacy-related concerns, email us at 116 | 120 | hello@srninety.one 121 | 122 |
123 |28 | {{ $key }} 29 | | 30 |{{ $val }} | 31 |
46 | {{ $key }} 47 | | 48 |{{ $val }} | 49 |
Effective Date: {{ .Year }}
7 | 8 |14 | By accessing or using Webhook Tester ("the Service"), you agree to be 15 | bound by these Terms and our 16 | Privacy Policy. If you do not agree, please do not use the Service. 19 |
20 |27 | You may use this Service solely for testing and debugging webhook 28 | integrations. 29 |
30 |48 | You are responsible for maintaining the confidentiality of your account 49 | and API key. Any activity under your account is your responsibility. 50 | Please notify us immediately if you suspect any unauthorized use. 51 |
52 |59 | We may automatically delete logs and data associated with public or 60 | inactive webhooks. It is your responsibility to export any information 61 | you need. 62 |
63 |70 | Webhook Tester is provided "as-is" without warranties of any kind. We 71 | are not liable for any loss or damage resulting from the use of the 72 | Service, including data loss, missed notifications, or service 73 | interruptions. 74 |
75 |82 | We may update these Terms at any time. Changes will be posted on this 83 | page. Continued use of the Service after changes implies acceptance. 84 |
85 |90 | For questions or concerns about these Terms, email us at 91 | 95 | hello@srninety.one 96 | 97 |
98 |