feat: implement core webhook engine, delivery system, and management UI (Phase 2)
All checks were successful
check / check (push) Successful in 1m49s
All checks were successful
check / check (push) Successful in 1m49s
- Webhook reception handler: look up entrypoint by UUID, verify active,
capture full HTTP request (method, headers, body, content-type), create
Event record, queue Delivery records for each active Target, return 200 OK.
Handles edge cases: unknown UUID → 404, inactive → 410, oversized → 413.
- Delivery engine (internal/delivery): fx-managed background goroutine that
polls for pending/retrying deliveries and dispatches to target type handlers.
Graceful shutdown via context cancellation.
- Target type implementations:
- HTTP: fire-and-forget POST with original headers forwarding
- Retry: exponential backoff (1s, 2s, 4s...) up to max_retries
- Database: immediate success (event already stored)
- Log: slog output with event details
- Webhook management pages with Tailwind CSS + Alpine.js:
- List (/sources): webhooks with entrypoint/target/event counts
- Create (/sources/new): form with auto-created default entrypoint
- Detail (/source/{id}): config, entrypoints, targets, recent events
- Edit (/source/{id}/edit): name, description, retention_days
- Delete (/source/{id}/delete): soft-delete with child records
- Add Entrypoint (/source/{id}/entrypoints): inline form
- Add Target (/source/{id}/targets): type-aware form
- Event Log (/source/{id}/logs): paginated with delivery status
- Updated README: marked completed items, updated naming conventions
table, added delivery engine to package layout and DI docs, updated
column names to reflect entity rename.
- Rebuilt Tailwind CSS for new template classes.
Part of: #15
This commit is contained in:
133
README.md
133
README.md
@@ -164,19 +164,14 @@ It uses:
|
|||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
This README uses the target naming scheme for the application's core
|
The codebase uses consistent naming throughout (rename completed in
|
||||||
entities. The current codebase uses older names that will be updated in
|
|
||||||
a future refactor (see
|
|
||||||
[issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)):
|
[issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)):
|
||||||
|
|
||||||
| README (target name) | Current code name | Description |
|
| Entity | Description |
|
||||||
| --------------------- | ----------------- | ----------- |
|
| ---------------- | ----------- |
|
||||||
| **Webhook** | `Processor` | Top-level configuration entity grouping entrypoints and targets |
|
| **Webhook** | Top-level configuration entity grouping entrypoints and targets |
|
||||||
| **Entrypoint** | `Webhook` | A receiver URL where external services POST events |
|
| **Entrypoint** | A receiver URL where external services POST events |
|
||||||
| **Target** | `Target` | A delivery destination for events |
|
| **Target** | A delivery destination for events |
|
||||||
|
|
||||||
Throughout this document, the target names are used. The code rename is
|
|
||||||
tracked separately.
|
|
||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
@@ -227,10 +222,10 @@ password logged to stdout.
|
|||||||
|
|
||||||
#### Webhook
|
#### Webhook
|
||||||
|
|
||||||
The top-level configuration entity (currently called "Processor" in
|
The top-level configuration entity. A webhook groups together one or
|
||||||
code). A webhook groups together one or more entrypoints (receiver URLs)
|
more entrypoints (receiver URLs) and one or more targets (delivery
|
||||||
and one or more targets (delivery destinations) into a logical unit. A
|
destinations) into a logical unit. A user creates a webhook to set up
|
||||||
user creates a webhook to set up event routing.
|
event routing.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | ------- | ----------- |
|
| ---------------- | ------- | ----------- |
|
||||||
@@ -247,15 +242,15 @@ webhook's dedicated database before automatic cleanup.
|
|||||||
|
|
||||||
#### Entrypoint
|
#### Entrypoint
|
||||||
|
|
||||||
A receiver URL where external services POST webhook events (currently
|
A receiver URL where external services POST webhook events. Each
|
||||||
called "Webhook" in code). Each entrypoint has a unique UUID-based path.
|
entrypoint has a unique UUID-based path.
|
||||||
When an HTTP request arrives at an entrypoint's path, webhooker captures
|
When an HTTP request arrives at an entrypoint's path, webhooker captures
|
||||||
the full request and creates an Event.
|
the full request and creates an Event.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| -------------- | ------- | ----------- |
|
| -------------- | ------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `processor_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `path` | string | Unique URL path (UUID-based, e.g. `/webhook/{uuid}`) |
|
| `path` | string | Unique URL path (UUID-based, e.g. `/webhook/{uuid}`) |
|
||||||
| `description` | string | Optional description |
|
| `description` | string | Optional description |
|
||||||
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||||
@@ -275,7 +270,7 @@ events should be forwarded.
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | ---------- | ----------- |
|
| ---------------- | ---------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `processor_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `name` | string | Human-readable name |
|
| `name` | string | Human-readable name |
|
||||||
| `type` | TargetType | One of: `http`, `retry`, `database`, `log` |
|
| `type` | TargetType | One of: `http`, `retry`, `database`, `log` |
|
||||||
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
||||||
@@ -320,9 +315,9 @@ data for replay and auditing.
|
|||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| -------------- | ------ | ----------- |
|
| -------------- | ------ | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `processor_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `webhook_id` | UUID | Foreign key → Entrypoint |
|
| `entrypoint_id` | UUID | Foreign key → Entrypoint |
|
||||||
| `method` | string | HTTP method (POST, PUT, etc.) |
|
| `method` | string | HTTP method (POST, PUT, etc.) |
|
||||||
| `headers` | JSON | Complete request headers |
|
| `headers` | JSON | Complete request headers |
|
||||||
| `body` | text | Raw request body |
|
| `body` | text | Raw request body |
|
||||||
@@ -406,8 +401,8 @@ configuration data and per-webhook databases for event storage.
|
|||||||
**Main Application Database** — will store:
|
**Main Application Database** — will store:
|
||||||
|
|
||||||
- **Users** — accounts and Argon2id password hashes
|
- **Users** — accounts and Argon2id password hashes
|
||||||
- **Webhooks** (Processors) — webhook configurations
|
- **Webhooks** — webhook configurations
|
||||||
- **Entrypoints** (Webhooks) — receiver URL definitions
|
- **Entrypoints** — receiver URL definitions
|
||||||
- **Targets** — delivery destination configurations
|
- **Targets** — delivery destination configurations
|
||||||
- **APIKeys** — programmatic access credentials
|
- **APIKeys** — programmatic access credentials
|
||||||
|
|
||||||
@@ -515,6 +510,8 @@ against a misbehaving sender).
|
|||||||
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/source/{id}/delete` | Delete webhook |
|
| `POST` | `/source/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
||||||
|
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
|
||||||
|
| `POST` | `/source/{id}/targets` | Add target to webhook |
|
||||||
|
|
||||||
#### Infrastructure Endpoints
|
#### Infrastructure Endpoints
|
||||||
|
|
||||||
@@ -554,8 +551,8 @@ webhooker/
|
|||||||
│ │ ├── database.go # GORM connection, migrations, admin seed
|
│ │ ├── database.go # GORM connection, migrations, admin seed
|
||||||
│ │ ├── models.go # AutoMigrate for all models
|
│ │ ├── models.go # AutoMigrate for all models
|
||||||
│ │ ├── model_user.go # User entity
|
│ │ ├── model_user.go # User entity
|
||||||
│ │ ├── model_processor.go # Webhook entity (to be renamed)
|
│ │ ├── model_webhook.go # Webhook entity
|
||||||
│ │ ├── model_webhook.go # Entrypoint entity (to be renamed)
|
│ │ ├── model_entrypoint.go # Entrypoint entity
|
||||||
│ │ ├── model_target.go # Target entity and TargetType enum
|
│ │ ├── model_target.go # Target entity and TargetType enum
|
||||||
│ │ ├── model_event.go # Event entity
|
│ │ ├── model_event.go # Event entity
|
||||||
│ │ ├── model_delivery.go # Delivery entity and DeliveryStatus enum
|
│ │ ├── model_delivery.go # Delivery entity and DeliveryStatus enum
|
||||||
@@ -564,13 +561,15 @@ webhooker/
|
|||||||
│ │ └── password.go # Argon2id hashing and verification
|
│ │ └── password.go # Argon2id hashing and verification
|
||||||
│ ├── globals/
|
│ ├── globals/
|
||||||
│ │ └── globals.go # Build-time variables (appname, version, arch)
|
│ │ └── globals.go # Build-time variables (appname, version, arch)
|
||||||
|
│ ├── delivery/
|
||||||
|
│ │ └── engine.go # Background delivery engine (fx lifecycle)
|
||||||
│ ├── handlers/
|
│ ├── handlers/
|
||||||
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
|
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
|
||||||
│ │ ├── auth.go # Login, logout handlers
|
│ │ ├── auth.go # Login, logout handlers
|
||||||
│ │ ├── healthcheck.go # Health check handler
|
│ │ ├── healthcheck.go # Health check handler
|
||||||
│ │ ├── index.go # Index page handler
|
│ │ ├── index.go # Index page handler
|
||||||
│ │ ├── profile.go # User profile handler
|
│ │ ├── profile.go # User profile handler
|
||||||
│ │ ├── source_management.go # Webhook CRUD handlers (stubs)
|
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||||
│ │ └── webhook.go # Webhook receiver handler
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
│ │ └── healthcheck.go # Health check service (uptime, version)
|
│ │ └── healthcheck.go # Health check service (uptime, version)
|
||||||
@@ -610,10 +609,11 @@ Components are wired via Uber fx in this order:
|
|||||||
6. `session.New` — Cookie-based session manager
|
6. `session.New` — Cookie-based session manager
|
||||||
7. `handlers.New` — HTTP handlers
|
7. `handlers.New` — HTTP handlers
|
||||||
8. `middleware.New` — HTTP middleware
|
8. `middleware.New` — HTTP middleware
|
||||||
9. `server.New` — HTTP server and router
|
9. `delivery.New` — Background delivery engine
|
||||||
|
10. `server.New` — HTTP server and router
|
||||||
|
|
||||||
The server starts via `fx.Invoke(func(*server.Server) {})` which
|
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine)
|
||||||
triggers the fx lifecycle hooks in dependency order.
|
{})` which triggers the fx lifecycle hooks in dependency order.
|
||||||
|
|
||||||
### Middleware Stack
|
### Middleware Stack
|
||||||
|
|
||||||
@@ -669,58 +669,57 @@ linted, tested, and compiled.
|
|||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
### Phase 1: Core Webhook Engine
|
### Completed: Code Quality (Phase 1 of MVP)
|
||||||
- [ ] Implement webhook reception and event storage at `/webhook/{uuid}`
|
- [x] Rename Processor → Webhook, Webhook → Entrypoint in code
|
||||||
- [ ] Build event processing and target delivery engine
|
([#12](https://git.eeqj.de/sneak/webhooker/issues/12))
|
||||||
- [ ] Implement HTTP target type (fire-and-forget POST)
|
- [x] Embed templates via `//go:embed`
|
||||||
- [ ] Implement retry target type (exponential backoff)
|
([#7](https://git.eeqj.de/sneak/webhooker/issues/7))
|
||||||
- [ ] Implement database target type (store only)
|
- [x] Use `slog.LevelVar` for dynamic log level switching
|
||||||
- [ ] Implement log target type (console output)
|
([#8](https://git.eeqj.de/sneak/webhooker/issues/8))
|
||||||
|
- [x] Simplify configuration to prefer environment variables
|
||||||
|
([#10](https://git.eeqj.de/sneak/webhooker/issues/10))
|
||||||
|
- [x] Remove redundant `godotenv/autoload` import
|
||||||
|
([#11](https://git.eeqj.de/sneak/webhooker/issues/11))
|
||||||
|
- [x] Implement authentication middleware for protected routes
|
||||||
|
([#9](https://git.eeqj.de/sneak/webhooker/issues/9))
|
||||||
|
- [x] Replace Bootstrap with Tailwind CSS + Alpine.js
|
||||||
|
([#4](https://git.eeqj.de/sneak/webhooker/issues/4))
|
||||||
|
|
||||||
|
### Completed: Core Webhook Engine (Phase 2 of MVP)
|
||||||
|
- [x] Implement webhook reception and event storage at `/webhook/{uuid}`
|
||||||
|
- [x] Build event processing and target delivery engine
|
||||||
|
- [x] Implement HTTP target type (fire-and-forget POST)
|
||||||
|
- [x] Implement retry target type (exponential backoff)
|
||||||
|
- [x] Implement database target type (store only)
|
||||||
|
- [x] Implement log target type (console output)
|
||||||
|
- [x] Webhook management pages (list, create, edit, delete)
|
||||||
|
- [x] Webhook request log viewer with pagination
|
||||||
|
- [x] Entrypoint and target management UI
|
||||||
|
|
||||||
|
### Remaining: Core Features
|
||||||
- [ ] Per-webhook rate limiting in the receiver handler
|
- [ ] Per-webhook rate limiting in the receiver handler
|
||||||
- [ ] Webhook signature verification (GitHub, Stripe formats)
|
- [ ] Webhook signature verification (GitHub, Stripe formats)
|
||||||
|
|
||||||
### Phase 2: Database Separation
|
|
||||||
- [ ] Split into main application DB + per-webhook event DBs
|
|
||||||
- [ ] Automatic event retention cleanup based on `retention_days`
|
|
||||||
- [ ] Per-webhook database lifecycle management (create on webhook
|
|
||||||
creation, delete on webhook removal)
|
|
||||||
|
|
||||||
### Phase 3: Security & Infrastructure
|
|
||||||
- [ ] Implement authentication middleware for protected routes
|
|
||||||
([#9](https://git.eeqj.de/sneak/webhooker/issues/9))
|
|
||||||
- [ ] Security headers (HSTS, CSP, X-Frame-Options)
|
- [ ] Security headers (HSTS, CSP, X-Frame-Options)
|
||||||
- [ ] CSRF protection for forms
|
- [ ] CSRF protection for forms
|
||||||
- [ ] Session expiration and "remember me"
|
- [ ] Session expiration and "remember me"
|
||||||
- [ ] Password change/reset flow
|
- [ ] Password change/reset flow
|
||||||
- [ ] API key authentication for programmatic access
|
- [ ] API key authentication for programmatic access
|
||||||
|
|
||||||
### Phase 4: Web UI
|
|
||||||
- [ ] Webhook management pages (list, create, edit, delete)
|
|
||||||
- [ ] Webhook request log viewer with filtering
|
|
||||||
- [ ] Delivery status and retry management UI
|
|
||||||
- [ ] Manual event redelivery
|
- [ ] Manual event redelivery
|
||||||
- [ ] Analytics dashboard (success rates, response times)
|
- [ ] Analytics dashboard (success rates, response times)
|
||||||
- [ ] Replace Bootstrap with Tailwind CSS + Alpine.js
|
- [ ] Delivery status and retry management UI
|
||||||
([#4](https://git.eeqj.de/sneak/webhooker/issues/4))
|
|
||||||
|
|
||||||
### Phase 5: REST API
|
### Remaining: Database Separation
|
||||||
|
- [ ] Split into main application DB + per-webhook event DBs
|
||||||
|
- [ ] Automatic event retention cleanup based on `retention_days`
|
||||||
|
- [ ] Per-webhook database lifecycle management (create on webhook
|
||||||
|
creation, delete on webhook removal)
|
||||||
|
|
||||||
|
### Remaining: REST API
|
||||||
- [ ] RESTful CRUD for webhooks, entrypoints, targets
|
- [ ] RESTful CRUD for webhooks, entrypoints, targets
|
||||||
- [ ] Event viewing and filtering endpoints
|
- [ ] Event viewing and filtering endpoints
|
||||||
- [ ] Event redelivery endpoint
|
- [ ] Event redelivery endpoint
|
||||||
- [ ] OpenAPI specification
|
- [ ] OpenAPI specification
|
||||||
|
|
||||||
### Phase 6: Code Quality
|
|
||||||
- [ ] Rename Processor → Webhook, Webhook → Entrypoint in code
|
|
||||||
([#12](https://git.eeqj.de/sneak/webhooker/issues/12))
|
|
||||||
- [ ] Embed templates via `//go:embed`
|
|
||||||
([#7](https://git.eeqj.de/sneak/webhooker/issues/7))
|
|
||||||
- [ ] Use `slog.LevelVar` for dynamic log level switching
|
|
||||||
([#8](https://git.eeqj.de/sneak/webhooker/issues/8))
|
|
||||||
- [ ] Simplify configuration to prefer environment variables
|
|
||||||
([#10](https://git.eeqj.de/sneak/webhooker/issues/10))
|
|
||||||
- [ ] Remove redundant `godotenv/autoload` import
|
|
||||||
([#11](https://git.eeqj.de/sneak/webhooker/issues/11))
|
|
||||||
|
|
||||||
### Future
|
### Future
|
||||||
- [ ] Email delivery target type
|
- [ ] Email delivery target type
|
||||||
- [ ] SNS, S3, Slack delivery targets
|
- [ ] SNS, S3, Slack delivery targets
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
@@ -36,8 +37,9 @@ func main() {
|
|||||||
session.New,
|
session.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
|
delivery.New,
|
||||||
server.New,
|
server.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(func(*server.Server) {}),
|
fx.Invoke(func(*server.Server, *delivery.Engine) {}),
|
||||||
).Run()
|
).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
383
internal/delivery/engine.go
Normal file
383
internal/delivery/engine.go
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// pollInterval is how often the engine checks for pending deliveries.
|
||||||
|
pollInterval = 2 * time.Second
|
||||||
|
|
||||||
|
// httpClientTimeout is the timeout for outbound HTTP requests.
|
||||||
|
httpClientTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// maxBodyLog is the maximum response body length to store in DeliveryResult.
|
||||||
|
maxBodyLog = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPTargetConfig holds configuration for http and retry target types.
|
||||||
|
type HTTPTargetConfig struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
Timeout int `json:"timeout,omitempty"` // seconds, 0 = default
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineParams are the fx dependencies for the delivery engine.
|
||||||
|
//
|
||||||
|
//nolint:revive // EngineParams is a standard fx naming convention
|
||||||
|
type EngineParams struct {
|
||||||
|
fx.In
|
||||||
|
DB *database.Database
|
||||||
|
Logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine processes queued deliveries in the background.
|
||||||
|
type Engine struct {
|
||||||
|
db *gorm.DB
|
||||||
|
log *slog.Logger
|
||||||
|
client *http.Client
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates and registers the delivery engine with the fx lifecycle.
|
||||||
|
func New(lc fx.Lifecycle, params EngineParams) *Engine {
|
||||||
|
e := &Engine{
|
||||||
|
db: params.DB.DB(),
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: httpClientTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
|
e.start()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
e.stop()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) start() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
e.cancel = cancel
|
||||||
|
e.wg.Add(1)
|
||||||
|
go e.run(ctx)
|
||||||
|
e.log.Info("delivery engine started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) stop() {
|
||||||
|
e.log.Info("delivery engine stopping")
|
||||||
|
e.cancel()
|
||||||
|
e.wg.Wait()
|
||||||
|
e.log.Info("delivery engine stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) run(ctx context.Context) {
|
||||||
|
defer e.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
e.processPending(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) processPending(ctx context.Context) {
|
||||||
|
var deliveries []database.Delivery
|
||||||
|
result := e.db.
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).
|
||||||
|
Preload("Target").
|
||||||
|
Preload("Event").
|
||||||
|
Find(&deliveries)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
e.log.Error("failed to query pending deliveries", "error", result.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range deliveries {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
e.processDelivery(ctx, &deliveries[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) processDelivery(ctx context.Context, d *database.Delivery) {
|
||||||
|
switch d.Target.Type {
|
||||||
|
case database.TargetTypeHTTP:
|
||||||
|
e.deliverHTTP(ctx, d)
|
||||||
|
case database.TargetTypeRetry:
|
||||||
|
e.deliverRetry(ctx, d)
|
||||||
|
case database.TargetTypeDatabase:
|
||||||
|
e.deliverDatabase(d)
|
||||||
|
case database.TargetTypeLog:
|
||||||
|
e.deliverLog(d)
|
||||||
|
default:
|
||||||
|
e.log.Error("unknown target type",
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"type", d.Target.Type,
|
||||||
|
)
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) deliverHTTP(_ context.Context, d *database.Delivery) {
|
||||||
|
cfg, err := e.parseHTTPConfig(d.Target.Config)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error("invalid HTTP target config",
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
e.recordResult(d, 1, false, 0, "", err.Error(), 0)
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode, respBody, duration, err := e.doHTTPRequest(cfg, &d.Event)
|
||||||
|
|
||||||
|
success := err == nil && statusCode >= 200 && statusCode < 300
|
||||||
|
errMsg := ""
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.recordResult(d, 1, success, statusCode, respBody, errMsg, duration)
|
||||||
|
|
||||||
|
if success {
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
|
||||||
|
} else {
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) deliverRetry(_ context.Context, d *database.Delivery) {
|
||||||
|
cfg, err := e.parseHTTPConfig(d.Target.Config)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error("invalid retry target config",
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
e.recordResult(d, 1, false, 0, "", err.Error(), 0)
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine attempt number from existing results
|
||||||
|
var resultCount int64
|
||||||
|
e.db.Model(&database.DeliveryResult{}).Where("delivery_id = ?", d.ID).Count(&resultCount)
|
||||||
|
attemptNum := int(resultCount) + 1
|
||||||
|
|
||||||
|
// Check if we should wait before retrying (exponential backoff)
|
||||||
|
if attemptNum > 1 {
|
||||||
|
var lastResult database.DeliveryResult
|
||||||
|
lookupErr := e.db.Where("delivery_id = ?", d.ID).Order("created_at DESC").First(&lastResult).Error
|
||||||
|
if lookupErr == nil {
|
||||||
|
shift := attemptNum - 2
|
||||||
|
if shift > 30 {
|
||||||
|
shift = 30
|
||||||
|
}
|
||||||
|
backoff := time.Duration(1<<uint(shift)) * time.Second //nolint:gosec // bounded above
|
||||||
|
nextAttempt := lastResult.CreatedAt.Add(backoff)
|
||||||
|
if time.Now().UTC().Before(nextAttempt) {
|
||||||
|
// Not time to retry yet
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode, respBody, duration, err := e.doHTTPRequest(cfg, &d.Event)
|
||||||
|
|
||||||
|
success := err == nil && statusCode >= 200 && statusCode < 300
|
||||||
|
errMsg := ""
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.recordResult(d, attemptNum, success, statusCode, respBody, errMsg, duration)
|
||||||
|
|
||||||
|
if success {
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxRetries := d.Target.MaxRetries
|
||||||
|
if maxRetries <= 0 {
|
||||||
|
maxRetries = 5 // default
|
||||||
|
}
|
||||||
|
|
||||||
|
if attemptNum >= maxRetries {
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
|
||||||
|
} else {
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusRetrying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) deliverDatabase(d *database.Delivery) {
|
||||||
|
// The event is already stored in the database; mark as delivered.
|
||||||
|
e.recordResult(d, 1, true, 0, "", "", 0)
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) deliverLog(d *database.Delivery) {
|
||||||
|
e.log.Info("webhook event delivered to log target",
|
||||||
|
"delivery_id", d.ID,
|
||||||
|
"event_id", d.EventID,
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"target_name", d.Target.Name,
|
||||||
|
"method", d.Event.Method,
|
||||||
|
"content_type", d.Event.ContentType,
|
||||||
|
"body_length", len(d.Event.Body),
|
||||||
|
)
|
||||||
|
e.recordResult(d, 1, true, 0, "", "", 0)
|
||||||
|
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doHTTPRequest performs the outbound HTTP POST to a target URL.
|
||||||
|
func (e *Engine) doHTTPRequest(cfg *HTTPTargetConfig, event *database.Event) (statusCode int, respBody string, durationMs int64, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, cfg.URL, bytes.NewReader([]byte(event.Body)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", 0, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content type from original event
|
||||||
|
if event.ContentType != "" {
|
||||||
|
req.Header.Set("Content-Type", event.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply original headers (filtered)
|
||||||
|
var originalHeaders map[string][]string
|
||||||
|
if event.Headers != "" {
|
||||||
|
if jsonErr := json.Unmarshal([]byte(event.Headers), &originalHeaders); jsonErr == nil {
|
||||||
|
for k, vals := range originalHeaders {
|
||||||
|
if isForwardableHeader(k) {
|
||||||
|
for _, v := range vals {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply target-specific headers (override)
|
||||||
|
for k, v := range cfg.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
|
||||||
|
client := e.client
|
||||||
|
if cfg.Timeout > 0 {
|
||||||
|
client = &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
durationMs = time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", durationMs, fmt.Errorf("sending request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyLog))
|
||||||
|
if readErr != nil {
|
||||||
|
return resp.StatusCode, "", durationMs, fmt.Errorf("reading response body: %w", readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode, string(body), durationMs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) recordResult(d *database.Delivery, attemptNum int, success bool, statusCode int, respBody, errMsg string, durationMs int64) {
|
||||||
|
result := &database.DeliveryResult{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
AttemptNum: attemptNum,
|
||||||
|
Success: success,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
ResponseBody: truncate(respBody, maxBodyLog),
|
||||||
|
Error: errMsg,
|
||||||
|
Duration: durationMs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.db.Create(result).Error; err != nil {
|
||||||
|
e.log.Error("failed to record delivery result",
|
||||||
|
"delivery_id", d.ID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) updateDeliveryStatus(d *database.Delivery, status database.DeliveryStatus) {
|
||||||
|
if err := e.db.Model(d).Update("status", status).Error; err != nil {
|
||||||
|
e.log.Error("failed to update delivery status",
|
||||||
|
"delivery_id", d.ID,
|
||||||
|
"status", status,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) parseHTTPConfig(configJSON string) (*HTTPTargetConfig, error) {
|
||||||
|
if configJSON == "" {
|
||||||
|
return nil, fmt.Errorf("empty target config")
|
||||||
|
}
|
||||||
|
var cfg HTTPTargetConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config JSON: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return nil, fmt.Errorf("target URL is required")
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isForwardableHeader returns true if the header should be forwarded to targets.
|
||||||
|
// Hop-by-hop headers and internal headers are excluded.
|
||||||
|
func isForwardableHeader(name string) bool {
|
||||||
|
switch http.CanonicalHeaderKey(name) {
|
||||||
|
case "Host", "Connection", "Keep-Alive", "Transfer-Encoding",
|
||||||
|
"Te", "Trailer", "Upgrade", "Proxy-Authorization",
|
||||||
|
"Proxy-Connection", "Content-Length":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen]
|
||||||
|
}
|
||||||
@@ -53,9 +53,14 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
|||||||
|
|
||||||
// Parse all page templates once at startup
|
// Parse all page templates once at startup
|
||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"index.html": parsePageTemplate("index.html"),
|
"index.html": parsePageTemplate("index.html"),
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
|
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||||
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
|
|||||||
@@ -1,69 +1,520 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleSourceList shows a list of user's webhooks
|
// WebhookListItem holds data for the webhook list view.
|
||||||
|
type WebhookListItem struct {
|
||||||
|
database.Webhook
|
||||||
|
EntrypointCount int64
|
||||||
|
TargetCount int64
|
||||||
|
EventCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSourceList shows a list of user's webhooks.
|
||||||
func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook list page
|
userID, ok := h.getUserID(r)
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhooks []database.Webhook
|
||||||
|
if err := h.db.DB().Where("user_id = ?", userID).Order("created_at DESC").Find(&webhooks).Error; err != nil {
|
||||||
|
h.log.Error("failed to list webhooks", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list items with counts
|
||||||
|
items := make([]WebhookListItem, len(webhooks))
|
||||||
|
for i := range webhooks {
|
||||||
|
items[i].Webhook = webhooks[i]
|
||||||
|
h.db.DB().Model(&database.Entrypoint{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].EntrypointCount)
|
||||||
|
h.db.DB().Model(&database.Target{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].TargetCount)
|
||||||
|
h.db.DB().Model(&database.Event{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].EventCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Webhooks": items,
|
||||||
|
}
|
||||||
|
h.renderTemplate(w, r, "sources_list.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceCreate shows the form to create a new webhook
|
// HandleSourceCreate shows the form to create a new webhook.
|
||||||
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook creation form
|
data := map[string]interface{}{
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
"Error": "",
|
||||||
|
}
|
||||||
|
h.renderTemplate(w, r, "sources_new.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceCreateSubmit handles the webhook creation form submission
|
// HandleSourceCreateSubmit handles the webhook creation form submission.
|
||||||
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook creation logic
|
userID, ok := h.getUserID(r)
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
description := r.FormValue("description")
|
||||||
|
retentionStr := r.FormValue("retention_days")
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Error": "Name is required",
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
h.renderTemplate(w, r, "sources_new.html", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionDays := 30
|
||||||
|
if retentionStr != "" {
|
||||||
|
if v, err := strconv.Atoi(retentionStr); err == nil && v > 0 {
|
||||||
|
retentionDays = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := h.db.DB().Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
h.log.Error("failed to begin transaction", "error", tx.Error)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhook := &database.Webhook{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(webhook).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
h.log.Error("failed to create webhook", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create one entrypoint
|
||||||
|
entrypoint := &database.Entrypoint{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
Description: "Default entrypoint",
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(entrypoint).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
h.log.Error("failed to create entrypoint", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
h.log.Error("failed to commit transaction", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info("webhook created",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"name", name,
|
||||||
|
"user_id", userID,
|
||||||
|
)
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceDetail shows details for a specific webhook
|
// HandleSourceDetail shows details for a specific webhook.
|
||||||
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook detail page
|
userID, ok := h.getUserID(r)
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entrypoints []database.Entrypoint
|
||||||
|
h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&entrypoints)
|
||||||
|
|
||||||
|
var targets []database.Target
|
||||||
|
h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&targets)
|
||||||
|
|
||||||
|
// Recent events with delivery info
|
||||||
|
var events []database.Event
|
||||||
|
h.db.DB().Where("webhook_id = ?", webhook.ID).Order("created_at DESC").Limit(20).Find(&events)
|
||||||
|
|
||||||
|
// Build host URL for display
|
||||||
|
host := r.Host
|
||||||
|
scheme := "https"
|
||||||
|
if r.TLS == nil {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
// Check X-Forwarded headers
|
||||||
|
if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
|
||||||
|
scheme = fwdProto
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Webhook": webhook,
|
||||||
|
"Entrypoints": entrypoints,
|
||||||
|
"Targets": targets,
|
||||||
|
"Events": events,
|
||||||
|
"BaseURL": scheme + "://" + host,
|
||||||
|
}
|
||||||
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceEdit shows the form to edit a webhook
|
// HandleSourceEdit shows the form to edit a webhook.
|
||||||
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook edit form
|
userID, ok := h.getUserID(r)
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Webhook": webhook,
|
||||||
|
"Error": "",
|
||||||
|
}
|
||||||
|
h.renderTemplate(w, r, "source_edit.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceEditSubmit handles the webhook edit form submission
|
// HandleSourceEditSubmit handles the webhook edit form submission.
|
||||||
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook update logic
|
userID, ok := h.getUserID(r)
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Webhook": webhook,
|
||||||
|
"Error": "Name is required",
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
h.renderTemplate(w, r, "source_edit.html", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhook.Name = name
|
||||||
|
webhook.Description = r.FormValue("description")
|
||||||
|
if retStr := r.FormValue("retention_days"); retStr != "" {
|
||||||
|
if v, err := strconv.Atoi(retStr); err == nil && v > 0 {
|
||||||
|
webhook.RetentionDays = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.DB().Save(&webhook).Error; err != nil {
|
||||||
|
h.log.Error("failed to update webhook", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceDelete handles webhook deletion
|
// HandleSourceDelete handles webhook deletion (soft delete).
|
||||||
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook deletion logic
|
userID, ok := h.getUserID(r)
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := h.db.DB().Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
h.log.Error("failed to begin transaction", "error", tx.Error)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete child records
|
||||||
|
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Entrypoint{})
|
||||||
|
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Target{})
|
||||||
|
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Event{})
|
||||||
|
tx.Delete(&webhook)
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
h.log.Error("failed to commit deletion", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info("webhook deleted", "webhook_id", webhook.ID, "user_id", userID)
|
||||||
|
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceLogs shows the request/response logs for a webhook
|
// HandleSourceLogs shows the request/response logs for a webhook.
|
||||||
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement webhook logs page
|
userID, ok := h.getUserID(r)
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
page := 1
|
||||||
|
if p := r.URL.Query().Get("page"); p != "" {
|
||||||
|
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
||||||
|
page = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
perPage := 25
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
var totalEvents int64
|
||||||
|
h.db.DB().Model(&database.Event{}).Where("webhook_id = ?", webhook.ID).Count(&totalEvents)
|
||||||
|
|
||||||
|
var events []database.Event
|
||||||
|
h.db.DB().Where("webhook_id = ?", webhook.ID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(perPage).
|
||||||
|
Find(&events)
|
||||||
|
|
||||||
|
// Load deliveries for each event
|
||||||
|
type EventWithDeliveries struct {
|
||||||
|
database.Event
|
||||||
|
Deliveries []database.Delivery
|
||||||
|
}
|
||||||
|
eventsWithDeliveries := make([]EventWithDeliveries, len(events))
|
||||||
|
for i := range events {
|
||||||
|
eventsWithDeliveries[i].Event = events[i]
|
||||||
|
h.db.DB().Where("event_id = ?", events[i].ID).Preload("Target").Find(&eventsWithDeliveries[i].Deliveries)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int(totalEvents) / perPage
|
||||||
|
if int(totalEvents)%perPage != 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Webhook": webhook,
|
||||||
|
"Events": eventsWithDeliveries,
|
||||||
|
"Page": page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"TotalEvents": totalEvents,
|
||||||
|
"HasPrev": page > 1,
|
||||||
|
"HasNext": page < totalPages,
|
||||||
|
"PrevPage": page - 1,
|
||||||
|
"NextPage": page + 1,
|
||||||
|
}
|
||||||
|
h.renderTemplate(w, r, "source_logs.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleEntrypointCreate handles adding a new entrypoint to a webhook.
|
||||||
|
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
var webhook database.Webhook
|
||||||
|
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
description := r.FormValue("description")
|
||||||
|
|
||||||
|
entrypoint := &database.Entrypoint{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
Description: description,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.DB().Create(entrypoint).Error; err != nil {
|
||||||
|
h.log.Error("failed to create entrypoint", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTargetCreate handles adding a new target to a webhook.
|
||||||
|
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
targetType := database.TargetType(r.FormValue("type"))
|
||||||
|
url := r.FormValue("url")
|
||||||
|
maxRetriesStr := r.FormValue("max_retries")
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target type
|
||||||
|
switch targetType {
|
||||||
|
case database.TargetTypeHTTP, database.TargetTypeRetry, database.TargetTypeDatabase, database.TargetTypeLog:
|
||||||
|
// valid
|
||||||
|
default:
|
||||||
|
http.Error(w, "Invalid target type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config JSON for HTTP-based targets
|
||||||
|
var configJSON string
|
||||||
|
if targetType == database.TargetTypeHTTP || targetType == database.TargetTypeRetry {
|
||||||
|
if url == "" {
|
||||||
|
http.Error(w, "URL is required for HTTP targets", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
configBytes, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configJSON = string(configBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxRetries := 5
|
||||||
|
if maxRetriesStr != "" {
|
||||||
|
if v, err := strconv.Atoi(maxRetriesStr); err == nil && v > 0 {
|
||||||
|
maxRetries = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target := &database.Target{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
Name: name,
|
||||||
|
Type: targetType,
|
||||||
|
Active: true,
|
||||||
|
Config: configJSON,
|
||||||
|
MaxRetries: maxRetries,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.DB().Create(target).Error; err != nil {
|
||||||
|
h.log.Error("failed to create target", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserID extracts the user ID from the session.
|
||||||
|
func (h *Handlers) getUserID(r *http.Request) (string, bool) {
|
||||||
|
sess, err := h.session.Get(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !h.session.IsAuthenticated(sess) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return h.session.GetUserID(sess)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,41 +1,135 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleWebhook handles incoming webhook requests at entrypoint URLs
|
const (
|
||||||
|
// maxWebhookBodySize is the maximum allowed webhook request body (1 MB).
|
||||||
|
maxWebhookBodySize = 1 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleWebhook handles incoming webhook requests at entrypoint URLs.
|
||||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get entrypoint UUID from URL
|
|
||||||
entrypointUUID := chi.URLParam(r, "uuid")
|
entrypointUUID := chi.URLParam(r, "uuid")
|
||||||
if entrypointUUID == "" {
|
if entrypointUUID == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the incoming webhook request
|
|
||||||
h.log.Info("webhook request received",
|
h.log.Info("webhook request received",
|
||||||
"entrypoint_uuid", entrypointUUID,
|
"entrypoint_uuid", entrypointUUID,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"user_agent", r.UserAgent(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only POST methods are allowed for webhooks
|
// Look up entrypoint by path
|
||||||
if r.Method != http.MethodPost {
|
var entrypoint database.Entrypoint
|
||||||
w.Header().Set("Allow", "POST")
|
result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint)
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
if result.Error != nil {
|
||||||
|
h.log.Debug("entrypoint not found", "path", entrypointUUID)
|
||||||
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement webhook handling logic
|
// Check if active
|
||||||
// Look up entrypoint by UUID, find parent webhook, fan out to targets
|
if !entrypoint.Active {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
http.Error(w, "Gone", http.StatusGone)
|
||||||
_, err := w.Write([]byte("unimplemented"))
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read body with size limit
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.log.Error("failed to read request body", "error", err)
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body) > maxWebhookBodySize {
|
||||||
|
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize headers as JSON
|
||||||
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("failed to serialize headers", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the event in a transaction
|
||||||
|
tx := h.db.DB().Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
h.log.Error("failed to begin transaction", "error", tx.Error)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: entrypoint.WebhookID,
|
||||||
|
EntrypointID: entrypoint.ID,
|
||||||
|
Method: r.Method,
|
||||||
|
Headers: string(headersJSON),
|
||||||
|
Body: string(body),
|
||||||
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(event).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
h.log.Error("failed to create event", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all active targets for this webhook
|
||||||
|
var targets []database.Target
|
||||||
|
if err := tx.Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
h.log.Error("failed to query targets", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create delivery records for each active target
|
||||||
|
for i := range targets {
|
||||||
|
delivery := &database.Delivery{
|
||||||
|
EventID: event.ID,
|
||||||
|
TargetID: targets[i].ID,
|
||||||
|
Status: database.DeliveryStatusPending,
|
||||||
|
}
|
||||||
|
if err := tx.Create(delivery).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
h.log.Error("failed to create delivery",
|
||||||
|
"target_id", targets[i].ID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
h.log.Error("failed to commit transaction", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info("webhook event created",
|
||||||
|
"event_id", event.ID,
|
||||||
|
"webhook_id", entrypoint.WebhookID,
|
||||||
|
"entrypoint_id", entrypoint.ID,
|
||||||
|
"target_count", len(targets),
|
||||||
|
)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
|
||||||
h.log.Error("failed to write response", "error", err)
|
h.log.Error("failed to write response", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,11 +100,13 @@ func (s *Server) SetupRoutes() {
|
|||||||
|
|
||||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceDetail()) // View webhook details
|
r.Get("/", s.h.HandleSourceDetail()) // View webhook details
|
||||||
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
|
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
|
||||||
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
|
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
|
||||||
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
|
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
|
||||||
r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
|
r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
|
||||||
|
r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint
|
||||||
|
r.Post("/targets", s.h.HandleTargetCreate()) // Add target
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entrypoint endpoint - accepts incoming webhook POST requests
|
// Entrypoint endpoint - accepts incoming webhook POST requests
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
154
templates/source_detail.html
Normal file
154
templates/source_detail.html
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
|
<div class="flex justify-between items-center mt-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||||
|
{{if .Webhook.Description}}
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
||||||
|
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
|
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||||
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Entrypoints -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
||||||
|
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add entrypoint form -->
|
||||||
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
|
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||||
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
|
<button type="submit" class="btn-primary text-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .Entrypoints}}
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
||||||
|
{{if .Active}}
|
||||||
|
<span class="badge-success">Active</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge-error">Inactive</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<code class="text-xs text-gray-500 break-all block mt-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Targets -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||||
|
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add target form -->
|
||||||
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
|
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||||
|
<select name="type" x-model="targetType" class="input text-sm w-32">
|
||||||
|
<option value="http">HTTP</option>
|
||||||
|
<option value="retry">Retry</option>
|
||||||
|
<option value="database">Database</option>
|
||||||
|
<option value="log">Log</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="targetType === 'http' || targetType === 'retry'">
|
||||||
|
<input type="url" name="url" placeholder="https://example.com/webhook" class="input text-sm">
|
||||||
|
</div>
|
||||||
|
<div x-show="targetType === 'retry'" class="flex gap-2 items-center">
|
||||||
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
|
<input type="number" name="max_retries" value="5" min="1" max="20" class="input text-sm w-24">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .Targets}}
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge-info">{{.Type}}</span>
|
||||||
|
{{if .Active}}
|
||||||
|
<span class="badge-success">Active</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge-error">Inactive</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .Config}}
|
||||||
|
<code class="text-xs text-gray-500 break-all block mt-1">{{.Config}}</code>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Events -->
|
||||||
|
<div class="card mt-6">
|
||||||
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
|
||||||
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .Events}}
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="badge-info">{{.Method}}</span>
|
||||||
|
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="p-8 text-center text-sm text-gray-500">No events received yet.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="mt-4 text-sm text-gray-400">
|
||||||
|
<p>Retention: {{.Webhook.RetentionDays}} days · Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
40
templates/source_edit.html
Normal file
40
templates/source_edit.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Edit {{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="alert-error">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="label">Name</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="label">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="3" class="input">{{.Webhook.Description}}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="retention_days" class="label">Retention (days)</label>
|
||||||
|
<input type="number" id="retention_days" name="retention_days" value="{{.Webhook.RetentionDays}}" min="1" max="365" class="input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
|
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
61
templates/source_logs.html
Normal file
61
templates/source_logs.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
|
<div class="flex justify-between items-center mt-2">
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
|
||||||
|
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .Events}}
|
||||||
|
<div class="p-4" x-data="{ open: false }">
|
||||||
|
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="badge-info">{{.Method}}</span>
|
||||||
|
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||||
|
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{{range .Deliveries}}
|
||||||
|
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">
|
||||||
|
{{.Target.Name}}: {{.Status}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
||||||
|
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{{if or .HasPrev .HasNext}}
|
||||||
|
<div class="flex justify-center gap-2 mt-6">
|
||||||
|
{{if .HasPrev}}
|
||||||
|
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||||
|
{{end}}
|
||||||
|
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||||
|
{{if .HasNext}}
|
||||||
|
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
49
templates/sources_list.html
Normal file
49
templates/sources_list.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Sources - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
||||||
|
<a href="/sources/new" class="btn-primary">
|
||||||
|
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
New Webhook
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Webhooks}}
|
||||||
|
<div class="grid gap-4">
|
||||||
|
{{range .Webhooks}}
|
||||||
|
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
||||||
|
{{if .Description}}
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="badge-info">{{.RetentionDays}}d retention</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-6 mt-4 text-sm text-gray-500">
|
||||||
|
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
|
||||||
|
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
|
||||||
|
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card p-12 text-center">
|
||||||
|
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
||||||
|
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
||||||
|
<a href="/sources/new" class="btn-primary">Create Webhook</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
41
templates/sources_new.html
Normal file
41
templates/sources_new.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}New Webhook - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="alert-error">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/sources/new" class="space-y-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="label">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required autofocus placeholder="My Webhook" class="input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="label">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="retention_days" class="label">Retention (days)</label>
|
||||||
|
<input type="number" id="retention_days" name="retention_days" value="30" min="1" max="365" class="input">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">How long to keep event data.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||||
|
<a href="/sources" class="btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user