feat: implement core webhook engine, delivery system, and management UI (Phase 2)
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:
clawbot
2026-03-01 16:14:28 -08:00
parent 853f25ee67
commit 7f8469a0f2
13 changed files with 1395 additions and 114 deletions

133
README.md
View File

@@ -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

View File

@@ -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
View 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]
}

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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)
} }
} }

View File

@@ -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

View 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">&larr; 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 &middot; Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</p>
</div>
</div>
{{end}}

View 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">&larr; 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}}

View 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">&larr; 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">&larr; 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 &rarr;</a>
{{end}}
</div>
{{end}}
</div>
{{end}}

View 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}}

View 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">&larr; 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}}