diff --git a/.gitignore b/.gitignore index 24d3d4e..d615704 100644 --- a/.gitignore +++ b/.gitignore @@ -29,9 +29,9 @@ Thumbs.db # Environment and config files .env .env.local -config.yaml -# Database files +# Data directory (SQLite databases) +data/ *.db *.sqlite *.sqlite3 diff --git a/Dockerfile b/Dockerfile index 9d2022d..9759259 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,6 @@ RUN set -eux; \ # Copy go module files and download dependencies COPY go.mod go.sum ./ -COPY pkg/config/go.mod pkg/config/go.sum ./pkg/config/ RUN go mod download # Copy source code @@ -57,7 +56,11 @@ WORKDIR /app # Copy binary from builder COPY --from=builder /build/bin/webhooker . -RUN chown -R webhooker:webhooker /app +# Create data directory for all SQLite databases (main app DB + +# per-webhook event DBs). DATA_DIR defaults to /data in production. +RUN mkdir -p /data + +RUN chown -R webhooker:webhooker /app /data USER webhooker diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3274443 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jeffrey Paul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a088e35..1927e93 100644 --- a/README.md +++ b/README.md @@ -50,30 +50,28 @@ make hooks # Install git pre-commit hook that runs make check ### Configuration -webhooker uses a YAML configuration file with environment-specific -overrides, loaded via the `pkg/config` library (Viper-based). The -environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev` or -`prod` (default: `dev`). +All configuration is via environment variables. For local development, +you can place variables in a `.env` file in the project root (loaded +automatically via `godotenv/autoload`). -Configuration is resolved in this order (highest priority first): - -1. Environment variables -2. `.env` file (loaded via `godotenv/autoload`) -3. Config file values for the active environment -4. Config file defaults +The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev` +or `prod` (default: `dev`). | Variable | Description | Default | | ----------------------- | ----------------------------------- | -------- | | `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `dev` | | `PORT` | HTTP listen port | `8080` | -| `DBURL` | SQLite database connection string | *(required)* | -| `SESSION_KEY` | Base64-encoded 32-byte session key | *(required in prod)* | +| `DATA_DIR` | Directory for all SQLite databases | `./data` (dev) / `/data` (prod) | | `DEBUG` | Enable debug logging | `false` | | `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` | | `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` | | `SENTRY_DSN` | Sentry error reporting DSN | `""` | -On first startup in development mode, webhooker creates an `admin` user +On first startup, webhooker automatically generates a cryptographically +secure session encryption key and stores it in the database. This key +persists across restarts — no manual key management is needed. + +On first startup, webhooker creates an `admin` user with a randomly generated password and logs it to stdout. This password is only displayed once. @@ -83,15 +81,16 @@ is only displayed once. docker run -d \ -p 8080:8080 \ -v /path/to/data:/data \ - -e DBURL="file:/data/webhooker.db?cache=shared&mode=rwc" \ - -e SESSION_KEY="" \ -e WEBHOOKER_ENVIRONMENT=prod \ webhooker:latest ``` The container runs as a non-root user (`webhooker`, UID 1000), exposes port 8080, and includes a health check against -`/.well-known/healthcheck`. +`/.well-known/healthcheck`. The `/data` volume holds all SQLite +databases: the main application database (`webhooker.db`) and the +per-webhook event databases (`events-{uuid}.db`). Mount this as a +persistent volume to preserve data across container restarts. ## Rationale @@ -164,19 +163,14 @@ It uses: ### Naming Conventions -This README uses the target naming scheme for the application's core -entities. The current codebase uses older names that will be updated in -a future refactor (see +The codebase uses consistent naming throughout (rename completed in [issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)): -| README (target name) | Current code name | Description | -| --------------------- | ----------------- | ----------- | -| **Webhook** | `Processor` | Top-level configuration entity grouping entrypoints and targets | -| **Entrypoint** | `Webhook` | A receiver URL where external services POST events | -| **Target** | `Target` | A delivery destination for events | - -Throughout this document, the target names are used. The code rename is -tracked separately. +| Entity | Description | +| ---------------- | ----------- | +| **Webhook** | Top-level configuration entity grouping entrypoints and targets | +| **Entrypoint** | A receiver URL where external services POST events | +| **Target** | A delivery destination for events | ### Data Model @@ -196,11 +190,15 @@ tier** (event ingestion, delivery, and logging). │ │ │ └──────────┘ └──────────────┘ │ │ │ │──1:N──│ APIKey │ │ │ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────┐ │ +│ │ Setting │ (key-value application config) │ +│ └──────────┘ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ EVENT TIER │ -│ (planned: per-webhook dedicated database) │ +│ (per-webhook dedicated databases) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │ │ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │ @@ -208,6 +206,22 @@ tier** (event ingestion, delivery, and logging). └─────────────────────────────────────────────────────────────┘ ``` +#### Setting + +A key-value pair for application-level configuration that is +auto-managed rather than user-provided. Used to store the session +encryption key and any future auto-generated settings. + +| Field | Type | Description | +| ------- | ------ | ----------- | +| `key` | string | Primary key (setting name) | +| `value` | text | Setting value | + +Currently stored settings: + +- **`session_key`** — Base64-encoded 32-byte session encryption key, + auto-generated on first startup. + #### User A registered user of the webhooker service. @@ -227,10 +241,10 @@ password logged to stdout. #### Webhook -The top-level configuration entity (currently called "Processor" in -code). A webhook groups together one or more entrypoints (receiver URLs) -and one or more targets (delivery destinations) into a logical unit. A -user creates a webhook to set up event routing. +The top-level configuration entity. A webhook groups together one or +more entrypoints (receiver URLs) and one or more targets (delivery +destinations) into a logical unit. A user creates a webhook to set up +event routing. | Field | Type | Description | | ---------------- | ------- | ----------- | @@ -247,15 +261,15 @@ webhook's dedicated database before automatic cleanup. #### Entrypoint -A receiver URL where external services POST webhook events (currently -called "Webhook" in code). Each entrypoint has a unique UUID-based path. +A receiver URL where external services POST webhook events. Each +entrypoint has a unique UUID-based path. When an HTTP request arrives at an entrypoint's path, webhooker captures the full request and creates an Event. | Field | Type | Description | | -------------- | ------- | ----------- | | `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}`) | | `description` | string | Optional description | | `active` | boolean | Whether this entrypoint accepts events (default: true) | @@ -275,24 +289,29 @@ events should be forwarded. | Field | Type | Description | | ---------------- | ---------- | ----------- | | `id` | UUID | Primary key | -| `processor_id` | UUID | Foreign key → Webhook | +| `webhook_id` | UUID | Foreign key → Webhook | | `name` | string | Human-readable name | -| `type` | TargetType | One of: `http`, `retry`, `database`, `log` | +| `type` | TargetType | One of: `http`, `database`, `log` | | `active` | boolean | Whether deliveries are enabled (default: true) | | `config` | JSON text | Type-specific configuration | -| `max_retries` | integer | Maximum retry attempts (for retry targets) | -| `max_queue_size` | integer | Maximum queued deliveries (for retry targets) | +| `max_retries` | integer | Maximum retry attempts for HTTP targets (0 = fire-and-forget, >0 = retries with backoff) | +| `max_queue_size` | integer | Maximum queued deliveries (for HTTP targets with retries) | **Relations:** Belongs to Webhook. Has many Deliveries. **Target types:** - **`http`** — Forward the event as an HTTP POST to a configured URL. - Fire-and-forget: a single attempt with no retries. -- **`retry`** — Forward the event via HTTP POST with automatic retry on - failure. Uses exponential backoff up to `max_retries` attempts. -- **`database`** — Store the event in the webhook's database only (no - external delivery). Useful for pure logging/archival. + Behavior depends on `max_retries`: when `max_retries` is 0 (the + default), the target operates in fire-and-forget mode — a single + attempt with no retries and no circuit breaker. When `max_retries` is + greater than 0, failed deliveries are retried with exponential backoff + up to `max_retries` attempts, protected by a per-target circuit + breaker. +- **`database`** — Confirm the event is stored in the webhook's + per-webhook database (no external delivery). Since events are always + written to the per-webhook DB on ingestion, this target marks delivery + as immediately successful. Useful for ensuring durable event archival. - **`log`** — Write the event to the application log (stdout). Useful for debugging. @@ -320,9 +339,9 @@ data for replay and auditing. | Field | Type | Description | | -------------- | ------ | ----------- | -| `id` | UUID | Primary key | -| `processor_id` | UUID | Foreign key → Webhook | -| `webhook_id` | UUID | Foreign key → Entrypoint | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `entrypoint_id` | UUID | Foreign key → Entrypoint | | `method` | string | HTTP method (POST, PUT, etc.) | | `headers` | JSON | Complete request headers | | `body` | text | Raw request body | @@ -389,36 +408,39 @@ All entities include these fields from `BaseModel`: ### Database Architecture -#### Current Implementation +#### Per-Webhook Event Databases -webhooker currently uses a **single SQLite database** for all data — -application configuration, user accounts, and (once implemented) event -storage. The database connection is managed by GORM with a single -connection string configured via `DBURL`. On first startup the database -is auto-migrated and an `admin` user is created. +webhooker uses **separate SQLite database files**: a main application +database for configuration data and per-webhook databases for event +storage. All database files live in the `DATA_DIR` directory. -#### Planned: Per-Webhook Event Databases (Phase 2) - -In a future phase (see TODO Phase 2 below), webhooker will split into -**separate SQLite database files**: a main application database for -configuration data and per-webhook databases for event storage. - -**Main Application Database** — will store: +**Main Application Database** (`{DATA_DIR}/webhooker.db`) — stores +configuration and application state: +- **Settings** — auto-managed key-value config (e.g. session encryption + key) - **Users** — accounts and Argon2id password hashes -- **Webhooks** (Processors) — webhook configurations -- **Entrypoints** (Webhooks) — receiver URL definitions +- **Webhooks** — webhook configurations +- **Entrypoints** — receiver URL definitions - **Targets** — delivery destination configurations - **APIKeys** — programmatic access credentials -**Per-Webhook Event Databases** — each webhook will get its own -dedicated SQLite file containing: +On first startup the main database is auto-migrated, a session +encryption key is generated and stored, and an `admin` user is created. + +**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) +— each webhook gets its own dedicated SQLite file containing: - **Events** — captured incoming webhook payloads - **Deliveries** — event-to-target pairings and their status - **DeliveryResults** — individual delivery attempt logs -This planned separation will provide: +Per-webhook databases are created automatically when a webhook is +created (and lazily on first access for webhooks that predate this +feature). They are managed by the `WebhookDBManager` component, which +handles connection pooling, lazy opening, migrations, and cleanup. + +This separation provides: - **Isolation** — a high-volume webhook won't cause lock contention or WAL bloat affecting the main application or other webhooks. @@ -426,14 +448,21 @@ This planned separation will provide: backed up, archived, rotated, or size-limited without impacting the application. - **Clean deletion** — removing a webhook and all its history is as - simple as deleting one file. + simple as deleting one file. Configuration is soft-deleted in the main + DB; the event database file is hard-deleted (permanently removed). - **Per-webhook retention** — the `retention_days` field on each webhook - will control automatic cleanup of old events in that webhook's - database only. -- **Performance** — each webhook's database will have its own WAL, its - own page cache, and its own lock, so concurrent event ingestion across + controls automatic cleanup of old events in that webhook's database + only. +- **Performance** — each webhook's database has its own WAL, its own + page cache, and its own lock, so concurrent event ingestion across webhooks won't contend. +The **database target type** leverages this architecture: since events +are already stored in the per-webhook database by design, the database +target simply marks the delivery as immediately successful. The +per-webhook DB IS the dedicated event database — that's the whole point +of the database target type. + The database uses the [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime, though CGO is required at build time due to the transitive @@ -453,22 +482,133 @@ External Service │ 1. Look up Entrypoint by UUID 2. Capture full request as Event - 3. Queue Delivery to each active Target + 3. Create Delivery records for each active Target + 4. Build self-contained DeliveryTask structs + (target config + event data inline for ≤16KB) + 5. Notify Engine via channel (no DB read needed) │ ▼ ┌──────────────┐ - │ Delivery │ - │ Engine │ + │ Delivery │◄── retry timers + │ Engine │ (backoff) + │ (worker │ + │ pool) │ └──────┬───────┘ │ - ┌────────────────────┼────────────────────┐ - ▼ ▼ ▼ - ┌────────────┐ ┌────────────┐ ┌────────────┐ - │ HTTP Target│ │Retry Target│ │ Log Target │ - │ (1 attempt)│ │ (backoff) │ │ (stdout) │ - └────────────┘ └────────────┘ └────────────┘ + ┌── bounded worker pool (N workers) ──┐ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │ HTTP Target│ │ HTTP Target│ │ Log Target │ + │(max_retries│ │(max_retries│ │ (stdout) │ + │ == 0) │ │ > 0, │ └────────────┘ + │ fire+forget│ │ backoff + │ + └────────────┘ │ circuit │ + │ breaker) │ + └────────────┘ ``` +### Bounded Worker Pool + +The delivery engine uses a **fixed-size worker pool** (default: 10 +workers) to process all deliveries. At most N deliveries are in-flight +at any time, preventing goroutine explosions regardless of queue depth. + +**Architecture:** + +- **Channels as queues:** Two buffered channels serve as bounded queues: + a delivery channel (new tasks from the webhook handler) and a retry + channel (tasks from backoff timers). Both are buffered to 10,000. +- **Fan-out via channel, not goroutines:** When an event arrives with + multiple targets, each `DeliveryTask` is sent to the delivery channel. + Workers pick them up and process them — no goroutine-per-target. +- **Worker goroutines:** A fixed number of worker goroutines select from + both channels. Each worker processes one task at a time, then picks up + the next. Workers are the ONLY goroutines doing actual HTTP delivery. +- **Retry backpressure with DB fallback:** When a retry timer fires and + the retry channel is full, the timer is dropped — the delivery stays + in `retrying` status in the database. A periodic sweep (every 60s) + scans for these "orphaned" retries and re-queues them. No blocked + goroutines, no unbounded timer chains. +- **Bounded concurrency:** At most N deliveries (N = number of workers) + are in-flight simultaneously. Even if a circuit breaker is open for + hours and thousands of retries queue up in the channels, the workers + drain them at a controlled rate when the circuit closes. + +This means: + +- **No goroutine explosion** — even with 10,000 queued retries, only + N worker goroutines exist. +- **Natural backpressure** — if workers are busy, new tasks wait in the + channel buffer rather than spawning more goroutines. +- **Independent results** — each worker records its own delivery result + in the per-webhook database without coordination. +- **Graceful shutdown** — cancel the context, workers finish their + current task and exit. `WaitGroup.Wait()` ensures clean shutdown. + +**Recovery paths:** + +1. **Startup recovery:** When the engine starts, it scans all per-webhook + databases for `pending` and `retrying` deliveries. Pending deliveries + are sent to the delivery channel; retrying deliveries get backoff + timers scheduled. +2. **Periodic retry sweep (DB-mediated fallback):** Every 60 seconds the + engine scans for `retrying` deliveries whose backoff period has + elapsed. This catches "orphaned" retries — ones whose in-memory timer + was dropped because the retry channel was full. The database is the + durable fallback that ensures no retry is permanently lost, even under + extreme backpressure. + +### Circuit Breaker (HTTP Targets with Retries) + +HTTP targets with `max_retries` > 0 are protected by a **per-target circuit breaker** that +prevents hammering a down target with repeated failed delivery attempts. +The circuit breaker is in-memory only and resets on restart (which is +fine — startup recovery rescans the database anyway). + +**States:** + +| State | Behavior | +| ----------- | -------- | +| **Closed** | Normal operation. Deliveries flow through. Consecutive failures are counted. | +| **Open** | Target appears down. Deliveries are skipped and rescheduled for after the cooldown. | +| **Half-Open** | Cooldown expired. One probe delivery is allowed to test if the target has recovered. | + +**Transitions:** + +``` + success ┌──────────┐ + ┌────────────────────► │ Closed │ ◄─── probe succeeds + │ │ (normal) │ + │ └────┬─────┘ + │ │ N consecutive failures + │ ▼ + │ ┌──────────┐ + │ │ Open │ ◄─── probe fails + │ │(tripped) │ + │ └────┬─────┘ + │ │ cooldown expires + │ ▼ + │ ┌──────────┐ + └──────────────────────│Half-Open │ + │ (probe) │ + └──────────┘ +``` + +**Defaults:** + +- **Failure threshold:** 5 consecutive failures before opening +- **Cooldown:** 30 seconds in open state before probing + +**Scope:** Circuit breakers only apply to **HTTP targets with +`max_retries` > 0**. Fire-and-forget HTTP targets (`max_retries` == 0), +database targets (local operations), and log targets (stdout) do not use +circuit breakers. + +When a circuit is open and a new delivery arrives, the engine marks the +delivery as `retrying` and schedules a retry timer for after the +remaining cooldown period. This ensures no deliveries are lost — they're +just delayed until the target is healthy again. + ### Rate Limiting Global rate limiting middleware (e.g., per-IP throttling applied at the @@ -515,6 +655,8 @@ against a misbehaving sender). | `POST` | `/source/{id}/edit` | Edit webhook submission | | `POST` | `/source/{id}/delete` | Delete webhook | | `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 @@ -548,29 +690,34 @@ webhooker/ │ └── main.go # Entry point: sets globals, wires fx ├── internal/ │ ├── config/ -│ │ └── config.go # Configuration loading via pkg/config +│ │ └── config.go # Configuration loading from environment variables │ ├── database/ │ │ ├── base_model.go # BaseModel with UUID primary keys │ │ ├── database.go # GORM connection, migrations, admin seed -│ │ ├── models.go # AutoMigrate for all models +│ │ ├── models.go # AutoMigrate for config-tier models +│ │ ├── model_setting.go # Setting entity (key-value app config) │ │ ├── model_user.go # User entity -│ │ ├── model_processor.go # Webhook entity (to be renamed) -│ │ ├── model_webhook.go # Entrypoint entity (to be renamed) +│ │ ├── model_webhook.go # Webhook entity +│ │ ├── model_entrypoint.go # Entrypoint entity │ │ ├── model_target.go # Target entity and TargetType enum -│ │ ├── model_event.go # Event entity -│ │ ├── model_delivery.go # Delivery entity and DeliveryStatus enum -│ │ ├── model_delivery_result.go # DeliveryResult entity +│ │ ├── model_event.go # Event entity (per-webhook DB) +│ │ ├── model_delivery.go # Delivery entity (per-webhook DB) +│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB) │ │ ├── model_apikey.go # APIKey entity -│ │ └── password.go # Argon2id hashing and verification +│ │ ├── password.go # Argon2id hashing and verification +│ │ └── webhook_db_manager.go # Per-webhook DB lifecycle manager │ ├── globals/ │ │ └── globals.go # Build-time variables (appname, version, arch) +│ ├── delivery/ +│ │ ├── engine.go # Event-driven delivery engine (channel + timer based) +│ │ └── circuit_breaker.go # Per-target circuit breaker for HTTP targets with retries │ ├── handlers/ │ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering │ │ ├── auth.go # Login, logout handlers │ │ ├── healthcheck.go # Health check handler │ │ ├── index.go # Index page handler │ │ ├── profile.go # User profile handler -│ │ ├── source_management.go # Webhook CRUD handlers (stubs) +│ │ ├── source_management.go # Webhook CRUD handlers │ │ └── webhook.go # Webhook receiver handler │ ├── healthcheck/ │ │ └── healthcheck.go # Health check service (uptime, version) @@ -584,14 +731,11 @@ webhooker/ │ │ └── routes.go # All route definitions │ └── session/ │ └── session.go # Cookie-based session management -├── pkg/config/ # Reusable multi-environment config library ├── static/ │ ├── static.go # //go:embed directive │ ├── css/style.css # Custom stylesheet (system font stack, card effects, layout) │ └── js/app.js # Client-side JavaScript (minimal bootstrap) ├── templates/ # Go HTML templates (base, index, login, etc.) -├── configs/ -│ └── config.yaml.example # Example configuration file ├── Dockerfile # Multi-stage: build + check, then Alpine runtime ├── Makefile # fmt, lint, test, check, build, docker targets ├── go.mod / go.sum @@ -604,16 +748,26 @@ Components are wired via Uber fx in this order: 1. `globals.New` — Build-time variables (appname, version, arch) 2. `logger.New` — Structured logging (slog with TTY detection) -3. `config.New` — Configuration loading (pkg/config + environment) -4. `database.New` — SQLite connection, migrations, admin user seed -5. `healthcheck.New` — Health check service -6. `session.New` — Cookie-based session manager -7. `handlers.New` — HTTP handlers -8. `middleware.New` — HTTP middleware -9. `server.New` — HTTP server and router +3. `config.New` — Configuration loading (environment variables) +4. `database.New` — Main SQLite connection, config migrations, admin + user seed +5. `database.NewWebhookDBManager` — Per-webhook event database + lifecycle manager +6. `healthcheck.New` — Health check service +7. `session.New` — Cookie-based session manager (key from database) +8. `handlers.New` — HTTP handlers +9. `middleware.New` — HTTP middleware +10. `delivery.New` — Event-driven delivery engine +11. `delivery.Engine` → `handlers.DeliveryNotifier` — interface bridge +12. `server.New` — HTTP server and router -The server starts via `fx.Invoke(func(*server.Server) {})` which -triggers the fx lifecycle hooks in dependency order. +The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine) +{})` which triggers the fx lifecycle hooks in dependency order. The +`DeliveryNotifier` interface allows the webhook handler to send +self-contained `DeliveryTask` slices to the engine without a direct +package dependency. Each task carries all target config and event data +inline (for bodies ≤16KB), so the engine can deliver without reading +from any database — it only writes to record results. ### Middleware Stack @@ -643,7 +797,8 @@ Applied to all routes in this order: - Passwords hashed with Argon2id (64 MB memory cost) - Session cookies are HttpOnly, SameSite Lax, Secure (prod only) -- Session key must be a 32-byte base64-encoded value +- Session key is a 32-byte value auto-generated on first startup and + stored in the database - Prometheus metrics behind basic auth - Static assets embedded in binary (no filesystem access needed at runtime) @@ -657,8 +812,9 @@ The Dockerfile uses a multi-stage build: 1. **Builder stage** (Debian-based `golang:1.24`) — installs golangci-lint, downloads dependencies, copies source, runs `make check` (format verification, linting, tests, compilation). -2. **Runtime stage** (`alpine:3.21`) — copies the binary, runs as - non-root user, exposes port 8080, includes a health check. +2. **Runtime stage** (`alpine:3.21`) — copies the binary, creates the + `/data` directory for all SQLite databases, runs as non-root user, + exposes port 8080, includes a health check. The builder uses Debian rather than Alpine because GORM's SQLite dialect pulls in CGO-dependent headers at compile time. The runtime @@ -669,58 +825,73 @@ linted, tested, and compiled. ## TODO -### Phase 1: Core Webhook Engine -- [ ] Implement webhook reception and event storage at `/webhook/{uuid}` -- [ ] Build event processing and target delivery engine -- [ ] Implement HTTP target type (fire-and-forget POST) -- [ ] Implement retry target type (exponential backoff) -- [ ] Implement database target type (store only) -- [ ] Implement log target type (console output) +### Completed: Code Quality (Phase 1 of MVP) +- [x] Rename Processor → Webhook, Webhook → Entrypoint in code + ([#12](https://git.eeqj.de/sneak/webhooker/issues/12)) +- [x] Embed templates via `//go:embed` + ([#7](https://git.eeqj.de/sneak/webhooker/issues/7)) +- [x] Use `slog.LevelVar` for dynamic log level switching + ([#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 with max_retries=0, + retries with exponential backoff when max_retries>0) +- [x] Implement database target type (store events in per-webhook DB) +- [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 + +### Completed: Per-Webhook Event Databases +- [x] Split into main application DB + per-webhook event DBs +- [x] Per-webhook database lifecycle management (create on webhook + creation, delete on webhook removal) +- [x] `WebhookDBManager` component with lazy connection pooling +- [x] Event-driven delivery engine (channel notifications + timer-based retries) +- [x] Self-contained delivery tasks: in the ≤16KB happy path, the engine + delivers without reading from any database — target config, event + headers, and body are all carried inline in the channel notification. + The engine only touches the DB to record results (success/failure). + Large bodies (≥16KB) are fetched from the per-webhook DB on demand. +- [x] Database target type marks delivery as immediately successful + (events are already in the per-webhook DB) +- [x] Parallel fan-out: all targets for an event are delivered via + the bounded worker pool (no goroutine-per-target) +- [x] Circuit breaker for HTTP targets with retries: tracks consecutive + failures per target, opens after 5 failures (30s cooldown), + half-open probe to test recovery + +### Remaining: Core Features - [ ] Per-webhook rate limiting in the receiver handler - [ ] 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) - [ ] CSRF protection for forms - [ ] Session expiration and "remember me" - [ ] Password change/reset flow - [ ] 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 - [ ] Analytics dashboard (success rates, response times) -- [ ] Replace Bootstrap with Tailwind CSS + Alpine.js - ([#4](https://git.eeqj.de/sneak/webhooker/issues/4)) +- [ ] Delivery status and retry management UI -### Phase 5: REST API +### Remaining: Event Maintenance +- [ ] Automatic event retention cleanup based on `retention_days` + +### Remaining: REST API - [ ] RESTful CRUD for webhooks, entrypoints, targets - [ ] Event viewing and filtering endpoints - [ ] Event redelivery endpoint - [ ] 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 - [ ] Email delivery target type - [ ] SNS, S3, Slack delivery targets diff --git a/cmd/webhooker/main.go b/cmd/webhooker/main.go index 86612b7..9273dd8 100644 --- a/cmd/webhooker/main.go +++ b/cmd/webhooker/main.go @@ -6,6 +6,7 @@ import ( "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/healthcheck" @@ -32,12 +33,17 @@ func main() { logger.New, config.New, database.New, + database.NewWebhookDBManager, healthcheck.New, session.New, handlers.New, middleware.New, + delivery.New, + // Wire *delivery.Engine as delivery.Notifier so the + // webhook handler can notify the engine of new deliveries. + func(e *delivery.Engine) delivery.Notifier { return e }, server.New, ), - fx.Invoke(func(*server.Server) {}), + fx.Invoke(func(*server.Server, *delivery.Engine) {}), ).Run() } diff --git a/configs/config.yaml.example b/configs/config.yaml.example deleted file mode 100644 index 3602d91..0000000 --- a/configs/config.yaml.example +++ /dev/null @@ -1,50 +0,0 @@ -environments: - dev: - config: - port: 8080 - debug: true - maintenanceMode: false - developmentMode: true - environment: dev - # Database URL for local development - dburl: postgres://webhooker:webhooker@localhost:5432/webhooker_dev?sslmode=disable - # Basic auth for metrics endpoint in dev - metricsUsername: admin - metricsPassword: admin - # Dev admin credentials for testing - devAdminUsername: devadmin - devAdminPassword: devpassword - secrets: - # Use default insecure session key for development - sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE= - # Sentry DSN - usually not needed in dev - sentryDSN: "" - - prod: - config: - port: $ENV:PORT - debug: $ENV:DEBUG - maintenanceMode: $ENV:MAINTENANCE_MODE - developmentMode: false - environment: prod - dburl: $ENV:DBURL - metricsUsername: $ENV:METRICS_USERNAME - metricsPassword: $ENV:METRICS_PASSWORD - # Dev admin credentials should not be set in production - devAdminUsername: "" - devAdminPassword: "" - secrets: - sessionKey: $ENV:SESSION_KEY - sentryDSN: $ENV:SENTRY_DSN - -configDefaults: - # These defaults apply to all environments unless overridden - port: 8080 - debug: false - maintenanceMode: false - developmentMode: false - environment: dev - metricsUsername: "" - metricsPassword: "" - devAdminUsername: "" - devAdminPassword: "" \ No newline at end of file diff --git a/go.mod b/go.mod index 2cca99e..84cbebe 100644 --- a/go.mod +++ b/go.mod @@ -14,35 +14,22 @@ require ( github.com/joho/godotenv v1.5.1 github.com/prometheus/client_golang v1.18.0 github.com/slok/go-http-metrics v0.11.0 - github.com/spf13/afero v1.14.0 github.com/stretchr/testify v1.8.4 go.uber.org/fx v1.20.1 golang.org/x/crypto v0.38.0 gorm.io/driver/sqlite v1.5.4 gorm.io/gorm v1.25.5 modernc.org/sqlite v1.28.0 - sneak.berlin/go/webhooker/pkg/config v0.0.0-00010101000000-000000000000 ) require ( - cloud.google.com/go/compute v1.23.3 // indirect - cloud.google.com/go/compute/metadata v0.2.3 // indirect - cloud.google.com/go/iam v1.1.5 // indirect - cloud.google.com/go/secretmanager v1.11.4 // indirect - github.com/aws/aws-sdk-go v1.50.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/s2a-go v0.1.7 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -53,25 +40,15 @@ require ( github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/dig v1.17.0 // indirect go.uber.org/multierr v1.9.0 // indirect go.uber.org/zap v1.23.0 // indirect golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/oauth2 v0.15.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect - golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - google.golang.org/api v0.153.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect - google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect @@ -84,5 +61,3 @@ require ( modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.0.1 // indirect ) - -replace sneak.berlin/go/webhooker/pkg/config => ./pkg/config diff --git a/go.sum b/go.sum index 1623663..1571d29 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,11 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= -cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic= -cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= -cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= -cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= -cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k= -cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI= -github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -30,10 +13,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI= github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= @@ -42,30 +21,7 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -73,15 +29,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= -github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= @@ -90,10 +39,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -117,7 +62,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= @@ -130,21 +74,12 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0= github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI= @@ -157,105 +92,32 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= -golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.153.0 h1:N1AwGhielyKFaUqH07/ZSIQR3uNPcV7NVw0vj+j4iR4= -google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= -google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo= -google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0= gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= diff --git a/internal/config/config.go b/internal/config/config.go index b78eae2..c21b8ca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,19 +4,15 @@ import ( "fmt" "log/slog" "os" + "strconv" + "strings" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" - pkgconfig "sneak.berlin/go/webhooker/pkg/config" - // spooky action at a distance! - // this populates the environment - // from a ./.env file automatically - // for development configuration. - // .env contents should be things like - // `DBURL=postgres://user:pass@.../` - // (without the backticks, of course) + // Populates the environment from a ./.env file automatically for + // development configuration. Kept in one place only (here). _ "github.com/joho/godotenv/autoload" ) @@ -25,9 +21,6 @@ const ( EnvironmentDev = "dev" // EnvironmentProd represents production environment EnvironmentProd = "prod" - // DevSessionKey is an insecure default session key for development - // This is "webhooker-dev-session-key-insecure!" base64 encoded - DevSessionKey = "d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=" ) // nolint:revive // ConfigParams is a standard fx naming convention @@ -38,20 +31,16 @@ type ConfigParams struct { } type Config struct { - DBURL string - Debug bool - MaintenanceMode bool - DevelopmentMode bool - DevAdminUsername string - DevAdminPassword string - Environment string - MetricsPassword string - MetricsUsername string - Port int - SentryDSN string - SessionKey string - params *ConfigParams - log *slog.Logger + DataDir string + Debug bool + MaintenanceMode bool + Environment string + MetricsPassword string + MetricsUsername string + Port int + SentryDSN string + params *ConfigParams + log *slog.Logger } // IsDev returns true if running in development environment @@ -64,6 +53,32 @@ func (c *Config) IsProd() bool { return c.Environment == EnvironmentProd } +// envString returns the value of the named environment variable, or +// an empty string if not set. +func envString(key string) string { + return os.Getenv(key) +} + +// envBool returns the value of the named environment variable parsed as a +// boolean. Returns defaultValue if not set. +func envBool(key string, defaultValue bool) bool { + if v := os.Getenv(key); v != "" { + return strings.EqualFold(v, "true") || v == "1" + } + return defaultValue +} + +// envInt returns the value of the named environment variable parsed as an +// integer. Returns defaultValue if not set or unparseable. +func envInt(key string, defaultValue int) int { + if v := os.Getenv(key); v != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return defaultValue +} + // nolint:revive // lc parameter is required by fx even if unused func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { log := params.Logger.Get() @@ -80,46 +95,32 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { EnvironmentDev, EnvironmentProd, environment) } - // Set the environment in the config package - pkgconfig.SetEnvironment(environment) - - // Load configuration values + // Load configuration values from environment variables s := &Config{ - DBURL: pkgconfig.GetString("dburl"), - Debug: pkgconfig.GetBool("debug"), - MaintenanceMode: pkgconfig.GetBool("maintenanceMode"), - DevelopmentMode: pkgconfig.GetBool("developmentMode"), - DevAdminUsername: pkgconfig.GetString("devAdminUsername"), - DevAdminPassword: pkgconfig.GetString("devAdminPassword"), - Environment: pkgconfig.GetString("environment", environment), - MetricsUsername: pkgconfig.GetString("metricsUsername"), - MetricsPassword: pkgconfig.GetString("metricsPassword"), - Port: pkgconfig.GetInt("port", 8080), - SentryDSN: pkgconfig.GetSecretString("sentryDSN"), - SessionKey: pkgconfig.GetSecretString("sessionKey"), - log: log, - params: ¶ms, + DataDir: envString("DATA_DIR"), + Debug: envBool("DEBUG", false), + MaintenanceMode: envBool("MAINTENANCE_MODE", false), + Environment: environment, + MetricsUsername: envString("METRICS_USERNAME"), + MetricsPassword: envString("METRICS_PASSWORD"), + Port: envInt("PORT", 8080), + SentryDSN: envString("SENTRY_DSN"), + log: log, + params: ¶ms, } - // Validate database URL - if s.DBURL == "" { - return nil, fmt.Errorf("database URL (dburl) is required") - } - - // In production, require session key - if s.IsProd() && s.SessionKey == "" { - return nil, fmt.Errorf("SESSION_KEY is required in production environment") - } - - // In development mode, warn if using default session key - if s.IsDev() && s.SessionKey == DevSessionKey { - log.Warn("Using insecure default session key for development mode") + // Set default DataDir based on environment. All SQLite databases + // (main application DB and per-webhook event DBs) live here. + if s.DataDir == "" { + if s.IsProd() { + s.DataDir = "/data" + } else { + s.DataDir = "./data" + } } if s.Debug { params.Logger.EnableDebugLogging() - s.log = params.Logger.Get() - log.Debug("Debug mode enabled") } // Log configuration summary (without secrets) @@ -128,8 +129,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { "port", s.Port, "debug", s.Debug, "maintenanceMode", s.MaintenanceMode, - "developmentMode", s.DevelopmentMode, - "hasSessionKey", s.SessionKey != "", + "dataDir", s.DataDir, "hasSentryDSN", s.SentryDSN != "", "hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "", ) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8435a95..f936344 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,66 +4,14 @@ import ( "os" "testing" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" - pkgconfig "sneak.berlin/go/webhooker/pkg/config" ) -// createTestConfig creates a test configuration file in memory -func createTestConfig(fs afero.Fs) error { - configYAML := ` -environments: - dev: - config: - port: 8080 - debug: true - maintenanceMode: false - developmentMode: true - environment: dev - dburl: postgres://test:test@localhost:5432/test_dev?sslmode=disable - metricsUsername: testuser - metricsPassword: testpass - devAdminUsername: devadmin - devAdminPassword: devpass - secrets: - sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE= - sentryDSN: "" - - prod: - config: - port: $ENV:PORT - debug: $ENV:DEBUG - maintenanceMode: $ENV:MAINTENANCE_MODE - developmentMode: false - environment: prod - dburl: $ENV:DBURL - metricsUsername: $ENV:METRICS_USERNAME - metricsPassword: $ENV:METRICS_PASSWORD - devAdminUsername: "" - devAdminPassword: "" - secrets: - sessionKey: $ENV:SESSION_KEY - sentryDSN: $ENV:SENTRY_DSN - -configDefaults: - port: 8080 - debug: false - maintenanceMode: false - developmentMode: false - environment: dev - metricsUsername: "" - metricsPassword: "" - devAdminUsername: "" - devAdminPassword: "" -` - return afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644) -} - func TestEnvironmentConfig(t *testing.T) { tests := []struct { name string @@ -76,6 +24,7 @@ func TestEnvironmentConfig(t *testing.T) { { name: "default is dev", envValue: "", + envVars: map[string]string{}, expectError: false, isDev: true, isProd: false, @@ -83,17 +32,15 @@ func TestEnvironmentConfig(t *testing.T) { { name: "explicit dev", envValue: "dev", + envVars: map[string]string{}, expectError: false, isDev: true, isProd: false, }, { - name: "explicit prod with session key", - envValue: "prod", - envVars: map[string]string{ - "SESSION_KEY": "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=", - "DBURL": "postgres://prod:prod@localhost:5432/prod?sslmode=require", - }, + name: "explicit prod", + envValue: "prod", + envVars: map[string]string{}, expectError: false, isDev: false, isProd: true, @@ -101,21 +48,19 @@ func TestEnvironmentConfig(t *testing.T) { { name: "invalid environment", envValue: "staging", + envVars: map[string]string{}, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create in-memory filesystem with test config - fs := afero.NewMemMapFs() - require.NoError(t, createTestConfig(fs)) - pkgconfig.SetFs(fs) - // Set environment variable if specified if tt.envValue != "" { os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue) defer os.Unsetenv("WEBHOOKER_ENVIRONMENT") + } else { + os.Unsetenv("WEBHOOKER_ENVIRONMENT") } // Set additional environment variables @@ -159,142 +104,3 @@ func TestEnvironmentConfig(t *testing.T) { }) } } - -func TestSessionKeyDefaults(t *testing.T) { - tests := []struct { - name string - environment string - sessionKey string - dburl string - expectError bool - expectedKey string - }{ - { - name: "dev mode with default session key", - environment: "dev", - sessionKey: "", - expectError: false, - expectedKey: DevSessionKey, - }, - { - name: "dev mode with custom session key", - environment: "dev", - sessionKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==", - expectError: false, - expectedKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==", - }, - { - name: "prod mode with no session key fails", - environment: "prod", - sessionKey: "", - dburl: "postgres://prod:prod@localhost:5432/prod", - expectError: true, - }, - { - name: "prod mode with session key succeeds", - environment: "prod", - sessionKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=", - dburl: "postgres://prod:prod@localhost:5432/prod", - expectError: false, - expectedKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create in-memory filesystem with test config - fs := afero.NewMemMapFs() - - // Create custom config for session key tests - configYAML := ` -environments: - dev: - config: - environment: dev - developmentMode: true - dburl: postgres://test:test@localhost:5432/test_dev - secrets:` - - // Only add sessionKey line if it's not empty - if tt.sessionKey != "" { - configYAML += ` - sessionKey: ` + tt.sessionKey - } else if tt.environment == "dev" { - // For dev mode with no session key, use the default - configYAML += ` - sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=` - } - - // Add prod config if testing prod - if tt.environment == "prod" { - configYAML += ` - prod: - config: - environment: prod - developmentMode: false - dburl: $ENV:DBURL - secrets: - sessionKey: $ENV:SESSION_KEY` - } - - require.NoError(t, afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644)) - pkgconfig.SetFs(fs) - - // Clean up any existing env vars - os.Unsetenv("WEBHOOKER_ENVIRONMENT") - os.Unsetenv("SESSION_KEY") - os.Unsetenv("DBURL") - - // Set environment variables - os.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment) - defer os.Unsetenv("WEBHOOKER_ENVIRONMENT") - - if tt.sessionKey != "" && tt.environment == "prod" { - os.Setenv("SESSION_KEY", tt.sessionKey) - defer os.Unsetenv("SESSION_KEY") - } - - if tt.dburl != "" { - os.Setenv("DBURL", tt.dburl) - defer os.Unsetenv("DBURL") - } - - if tt.expectError { - // Use regular fx.New for error cases - var cfg *Config - app := fx.New( - fx.NopLogger, // Suppress fx logs in tests - fx.Provide( - globals.New, - logger.New, - New, - ), - fx.Populate(&cfg), - ) - assert.Error(t, app.Err()) - } else { - // Use fxtest for success cases - var cfg *Config - app := fxtest.New( - t, - fx.Provide( - globals.New, - logger.New, - New, - ), - fx.Populate(&cfg), - ) - require.NoError(t, app.Err()) - app.RequireStart() - defer app.RequireStop() - - if tt.environment == "dev" && tt.sessionKey == "" { - // Dev mode with no session key uses default - assert.Equal(t, DevSessionKey, cfg.SessionKey) - } else { - assert.Equal(t, tt.expectedKey, cfg.SessionKey) - } - } - }) - } -} diff --git a/internal/database/database.go b/internal/database/database.go index 90d3fe5..933a9ca 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -2,8 +2,14 @@ package database import ( "context" + "crypto/rand" "database/sql" + "encoding/base64" + "errors" + "fmt" "log/slog" + "os" + "path/filepath" "go.uber.org/fx" "gorm.io/driver/sqlite" @@ -45,13 +51,17 @@ func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) { } func (d *Database) connect() error { - dbURL := d.params.Config.DBURL - if dbURL == "" { - // Default to SQLite for development - dbURL = "file:webhooker.db?cache=shared&mode=rwc" + // Ensure the data directory exists before opening the database. + dataDir := d.params.Config.DataDir + if err := os.MkdirAll(dataDir, 0750); err != nil { + return fmt.Errorf("creating data directory %s: %w", dataDir, err) } - // First, open the database with the pure Go driver + // Construct the main application database path inside DATA_DIR. + dbPath := filepath.Join(dataDir, "webhooker.db") + dbURL := fmt.Sprintf("file:%s?cache=shared&mode=rwc", dbPath) + + // Open the database with the pure Go SQLite driver sqlDB, err := sql.Open("sqlite", dbURL) if err != nil { d.log.Error("failed to open database", "error", err) @@ -68,7 +78,7 @@ func (d *Database) connect() error { } d.db = db - d.log.Info("connected to database", "database", dbURL) + d.log.Info("connected to database", "path", dbPath) // Run migrations return d.migrate() @@ -118,11 +128,11 @@ func (d *Database) migrate() error { return err } - // Log the password - this will only happen once on first startup d.log.Info("admin user created", "username", "admin", "password", password, - "message", "SAVE THIS PASSWORD - it will not be shown again!") + "message", "SAVE THIS PASSWORD - it will not be shown again!", + ) } return nil @@ -142,3 +152,35 @@ func (d *Database) close() error { func (d *Database) DB() *gorm.DB { return d.db } + +// GetOrCreateSessionKey retrieves the session encryption key from the +// settings table. If no key exists, a cryptographically secure random +// 32-byte key is generated, base64-encoded, and stored for future use. +func (d *Database) GetOrCreateSessionKey() (string, error) { + var setting Setting + result := d.db.Where(&Setting{Key: "session_key"}).First(&setting) + if result.Error == nil { + return setting.Value, nil + } + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", fmt.Errorf("failed to query session key: %w", result.Error) + } + + // Generate a new cryptographically secure 32-byte key + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return "", fmt.Errorf("failed to generate session key: %w", err) + } + encoded := base64.StdEncoding.EncodeToString(keyBytes) + + setting = Setting{ + Key: "session_key", + Value: encoded, + } + if err := d.db.Create(&setting).Error; err != nil { + return "", fmt.Errorf("failed to store session key: %w", err) + } + + d.log.Info("generated new session key and stored in database") + return encoded, nil +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 996fd60..fd7ce16 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -4,38 +4,13 @@ import ( "context" "testing" - "github.com/spf13/afero" "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" - pkgconfig "sneak.berlin/go/webhooker/pkg/config" ) func TestDatabaseConnection(t *testing.T) { - // Set up in-memory config so the test does not depend on config.yaml on disk - fs := afero.NewMemMapFs() - testConfigYAML := ` -environments: - dev: - config: - port: 8080 - debug: false - maintenanceMode: false - developmentMode: true - environment: dev - dburl: "file::memory:?cache=shared" - secrets: - sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE= - sentryDSN: "" -configDefaults: - port: 8080 -` - if err := afero.WriteFile(fs, "config.yaml", []byte(testConfigYAML), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - pkgconfig.SetFs(fs) - // Set up test dependencies lc := fxtest.NewLifecycle(t) @@ -55,18 +30,12 @@ configDefaults: t.Fatalf("Failed to create logger: %v", err) } - // Create config - c, err := config.New(lc, config.ConfigParams{ - Globals: g, - Logger: l, - }) - if err != nil { - t.Fatalf("Failed to create config: %v", err) + // Create config with DataDir pointing to a temp directory + c := &config.Config{ + DataDir: t.TempDir(), + Environment: "dev", } - // Override DBURL to use a temp file-based SQLite (in-memory doesn't persist across connections) - c.DBURL = "file:" + t.TempDir() + "/test.db?cache=shared&mode=rwc" - // Create database db, err := New(lc, DatabaseParams{ Config: c, diff --git a/internal/database/model_entrypoint.go b/internal/database/model_entrypoint.go new file mode 100644 index 0000000..37b7e3b --- /dev/null +++ b/internal/database/model_entrypoint.go @@ -0,0 +1,14 @@ +package database + +// Entrypoint represents an inbound URL endpoint that feeds into a webhook +type Entrypoint struct { + BaseModel + + WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"` + Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint + Description string `json:"description"` + Active bool `gorm:"default:true" json:"active"` + + // Relations + Webhook Webhook `json:"webhook,omitempty"` +} diff --git a/internal/database/model_event.go b/internal/database/model_event.go index 7b6ea50..f9dbaed 100644 --- a/internal/database/model_event.go +++ b/internal/database/model_event.go @@ -1,11 +1,11 @@ package database -// Event represents a webhook event +// Event represents a captured webhook event type Event struct { BaseModel - ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"` - WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"` + WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"` + EntrypointID string `gorm:"type:uuid;not null" json:"entrypoint_id"` // Request data Method string `gorm:"not null" json:"method"` @@ -14,7 +14,7 @@ type Event struct { ContentType string `json:"content_type"` // Relations - Processor Processor `json:"processor,omitempty"` Webhook Webhook `json:"webhook,omitempty"` + Entrypoint Entrypoint `json:"entrypoint,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"` } diff --git a/internal/database/model_processor.go b/internal/database/model_processor.go deleted file mode 100644 index 121b0c1..0000000 --- a/internal/database/model_processor.go +++ /dev/null @@ -1,16 +0,0 @@ -package database - -// Processor represents an event processor -type Processor struct { - BaseModel - - UserID string `gorm:"type:uuid;not null" json:"user_id"` - Name string `gorm:"not null" json:"name"` - Description string `json:"description"` - RetentionDays int `gorm:"default:30" json:"retention_days"` // Days to retain events - - // Relations - User User `json:"user,omitempty"` - Webhooks []Webhook `json:"webhooks,omitempty"` - Targets []Target `json:"targets,omitempty"` -} diff --git a/internal/database/model_setting.go b/internal/database/model_setting.go new file mode 100644 index 0000000..b39cc53 --- /dev/null +++ b/internal/database/model_setting.go @@ -0,0 +1,8 @@ +package database + +// Setting stores application-level key-value configuration. +// Used for auto-generated values like the session encryption key. +type Setting struct { + Key string `gorm:"primaryKey" json:"key"` + Value string `gorm:"type:text;not null" json:"value"` +} diff --git a/internal/database/model_target.go b/internal/database/model_target.go index 76478ba..e9c4628 100644 --- a/internal/database/model_target.go +++ b/internal/database/model_target.go @@ -5,28 +5,27 @@ type TargetType string const ( TargetTypeHTTP TargetType = "http" - TargetTypeRetry TargetType = "retry" TargetTypeDatabase TargetType = "database" TargetTypeLog TargetType = "log" ) -// Target represents a delivery target for a processor +// Target represents a delivery target for a webhook type Target struct { BaseModel - ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"` - Name string `gorm:"not null" json:"name"` - Type TargetType `gorm:"not null" json:"type"` - Active bool `gorm:"default:true" json:"active"` + WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"` + Name string `gorm:"not null" json:"name"` + Type TargetType `gorm:"not null" json:"type"` + Active bool `gorm:"default:true" json:"active"` // Configuration fields (JSON stored based on type) Config string `gorm:"type:text" json:"config"` // JSON configuration - // For retry targets + // For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff) MaxRetries int `json:"max_retries,omitempty"` MaxQueueSize int `json:"max_queue_size,omitempty"` // Relations - Processor Processor `json:"processor,omitempty"` + Webhook Webhook `json:"webhook,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"` } diff --git a/internal/database/model_user.go b/internal/database/model_user.go index b31afdb..6a578d0 100644 --- a/internal/database/model_user.go +++ b/internal/database/model_user.go @@ -8,6 +8,6 @@ type User struct { Password string `gorm:"not null" json:"-"` // Argon2 hashed // Relations - Processors []Processor `json:"processors,omitempty"` - APIKeys []APIKey `json:"api_keys,omitempty"` + Webhooks []Webhook `json:"webhooks,omitempty"` + APIKeys []APIKey `json:"api_keys,omitempty"` } diff --git a/internal/database/model_webhook.go b/internal/database/model_webhook.go index 7fae55f..08e4bc4 100644 --- a/internal/database/model_webhook.go +++ b/internal/database/model_webhook.go @@ -1,14 +1,16 @@ package database -// Webhook represents a webhook endpoint that feeds into a processor +// Webhook represents a webhook processing unit that groups entrypoints and targets type Webhook struct { BaseModel - ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"` - Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this webhook - Description string `json:"description"` - Active bool `gorm:"default:true" json:"active"` + UserID string `gorm:"type:uuid;not null" json:"user_id"` + Name string `gorm:"not null" json:"name"` + Description string `json:"description"` + RetentionDays int `gorm:"default:30" json:"retention_days"` // Days to retain events // Relations - Processor Processor `json:"processor,omitempty"` + User User `json:"user,omitempty"` + Entrypoints []Entrypoint `json:"entrypoints,omitempty"` + Targets []Target `json:"targets,omitempty"` } diff --git a/internal/database/models.go b/internal/database/models.go index 560fdce..0857a74 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -1,15 +1,16 @@ package database -// Migrate runs database migrations for all models +// Migrate runs database migrations for the main application database. +// Only configuration-tier models are stored in the main database. +// Event-tier models (Event, Delivery, DeliveryResult) live in +// per-webhook dedicated databases managed by WebhookDBManager. func (d *Database) Migrate() error { return d.db.AutoMigrate( + &Setting{}, &User{}, &APIKey{}, - &Processor{}, &Webhook{}, + &Entrypoint{}, &Target{}, - &Event{}, - &Delivery{}, - &DeliveryResult{}, ) } diff --git a/internal/database/webhook_db_manager.go b/internal/database/webhook_db_manager.go new file mode 100644 index 0000000..56e19be --- /dev/null +++ b/internal/database/webhook_db_manager.go @@ -0,0 +1,183 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + + "go.uber.org/fx" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/logger" +) + +// nolint:revive // WebhookDBManagerParams is a standard fx naming convention +type WebhookDBManagerParams struct { + fx.In + Config *config.Config + Logger *logger.Logger +} + +// WebhookDBManager manages per-webhook SQLite database files for event storage. +// Each webhook gets its own dedicated database containing Events, Deliveries, +// and DeliveryResults. Database connections are opened lazily and cached. +type WebhookDBManager struct { + dataDir string + dbs sync.Map // map[webhookID]*gorm.DB + log *slog.Logger +} + +// NewWebhookDBManager creates a new WebhookDBManager and registers lifecycle hooks. +func NewWebhookDBManager(lc fx.Lifecycle, params WebhookDBManagerParams) (*WebhookDBManager, error) { + m := &WebhookDBManager{ + dataDir: params.Config.DataDir, + log: params.Logger.Get(), + } + + // Create data directory if it doesn't exist + if err := os.MkdirAll(m.dataDir, 0750); err != nil { + return nil, fmt.Errorf("creating data directory %s: %w", m.dataDir, err) + } + + lc.Append(fx.Hook{ + OnStop: func(_ context.Context) error { //nolint:revive // ctx unused but required by fx + return m.CloseAll() + }, + }) + + m.log.Info("webhook database manager initialized", "data_dir", m.dataDir) + return m, nil +} + +// dbPath returns the filesystem path for a webhook's database file. +func (m *WebhookDBManager) dbPath(webhookID string) string { + return filepath.Join(m.dataDir, fmt.Sprintf("events-%s.db", webhookID)) +} + +// openDB opens (or creates) a per-webhook SQLite database and runs migrations. +func (m *WebhookDBManager) openDB(webhookID string) (*gorm.DB, error) { + path := m.dbPath(webhookID) + dbURL := fmt.Sprintf("file:%s?cache=shared&mode=rwc", path) + + sqlDB, err := sql.Open("sqlite", dbURL) + if err != nil { + return nil, fmt.Errorf("opening webhook database %s: %w", webhookID, err) + } + + db, err := gorm.Open(sqlite.Dialector{ + Conn: sqlDB, + }, &gorm.Config{}) + if err != nil { + sqlDB.Close() + return nil, fmt.Errorf("connecting to webhook database %s: %w", webhookID, err) + } + + // Run migrations for event-tier models only + if err := db.AutoMigrate(&Event{}, &Delivery{}, &DeliveryResult{}); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("migrating webhook database %s: %w", webhookID, err) + } + + m.log.Info("opened per-webhook database", "webhook_id", webhookID, "path", path) + return db, nil +} + +// GetDB returns the database connection for a webhook, creating the database +// file lazily if it doesn't exist. This handles both new webhooks and existing +// webhooks that were created before per-webhook databases were introduced. +func (m *WebhookDBManager) GetDB(webhookID string) (*gorm.DB, error) { + // Fast path: already open + if val, ok := m.dbs.Load(webhookID); ok { + cachedDB, castOK := val.(*gorm.DB) + if !castOK { + return nil, fmt.Errorf("invalid cached database type for webhook %s", webhookID) + } + return cachedDB, nil + } + + // Slow path: open/create the database + db, err := m.openDB(webhookID) + if err != nil { + return nil, err + } + + // Store it; if another goroutine beat us, close ours and use theirs + actual, loaded := m.dbs.LoadOrStore(webhookID, db) + if loaded { + // Another goroutine created it first; close our duplicate + if sqlDB, closeErr := db.DB(); closeErr == nil { + sqlDB.Close() + } + existingDB, castOK := actual.(*gorm.DB) + if !castOK { + return nil, fmt.Errorf("invalid cached database type for webhook %s", webhookID) + } + return existingDB, nil + } + + return db, nil +} + +// CreateDB explicitly creates a new per-webhook database file and runs migrations. +// This is called when a new webhook is created. +func (m *WebhookDBManager) CreateDB(webhookID string) error { + _, err := m.GetDB(webhookID) + return err +} + +// DBExists checks if a per-webhook database file exists on disk. +func (m *WebhookDBManager) DBExists(webhookID string) bool { + _, err := os.Stat(m.dbPath(webhookID)) + return err == nil +} + +// DeleteDB closes the connection and deletes the database file for a webhook. +// This performs a hard delete — the file is permanently removed. +func (m *WebhookDBManager) DeleteDB(webhookID string) error { + // Close and remove from cache + if val, ok := m.dbs.LoadAndDelete(webhookID); ok { + if gormDB, castOK := val.(*gorm.DB); castOK { + if sqlDB, err := gormDB.DB(); err == nil { + sqlDB.Close() + } + } + } + + // Delete the main DB file and WAL/SHM files + path := m.dbPath(webhookID) + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.Remove(path + suffix); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("deleting webhook database file %s%s: %w", path, suffix, err) + } + } + + m.log.Info("deleted per-webhook database", "webhook_id", webhookID) + return nil +} + +// CloseAll closes all open per-webhook database connections. +// Called during application shutdown. +func (m *WebhookDBManager) CloseAll() error { + var lastErr error + m.dbs.Range(func(key, value interface{}) bool { + if gormDB, castOK := value.(*gorm.DB); castOK { + if sqlDB, err := gormDB.DB(); err == nil { + if closeErr := sqlDB.Close(); closeErr != nil { + lastErr = closeErr + m.log.Error("failed to close webhook database", + "webhook_id", key, + "error", closeErr, + ) + } + } + } + m.dbs.Delete(key) + return true + }) + return lastErr +} diff --git a/internal/database/webhook_db_manager_test.go b/internal/database/webhook_db_manager_test.go new file mode 100644 index 0000000..7f16116 --- /dev/null +++ b/internal/database/webhook_db_manager_test.go @@ -0,0 +1,273 @@ +package database + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/fx/fxtest" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/globals" + "sneak.berlin/go/webhooker/internal/logger" +) + +func setupTestWebhookDBManager(t *testing.T) (*WebhookDBManager, *fxtest.Lifecycle) { + t.Helper() + + lc := fxtest.NewLifecycle(t) + + globals.Appname = "webhooker-test" + globals.Version = "test" + globals.Buildarch = "test" + + g, err := globals.New(lc) + require.NoError(t, err) + + l, err := logger.New(lc, logger.LoggerParams{Globals: g}) + require.NoError(t, err) + + dataDir := filepath.Join(t.TempDir(), "events") + + cfg := &config.Config{ + DataDir: dataDir, + } + + mgr, err := NewWebhookDBManager(lc, WebhookDBManagerParams{ + Config: cfg, + Logger: l, + }) + require.NoError(t, err) + + return mgr, lc +} + +func TestWebhookDBManager_CreateAndGetDB(t *testing.T) { + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() + + // DB should not exist yet + assert.False(t, mgr.DBExists(webhookID)) + + // Create the DB + err := mgr.CreateDB(webhookID) + require.NoError(t, err) + + // DB file should now exist + assert.True(t, mgr.DBExists(webhookID)) + + // Get the DB again (should use cached connection) + db, err := mgr.GetDB(webhookID) + require.NoError(t, err) + require.NotNil(t, db) + + // Verify we can write an event + event := &Event{ + WebhookID: webhookID, + EntrypointID: uuid.New().String(), + Method: "POST", + Headers: `{"Content-Type":["application/json"]}`, + Body: `{"test": true}`, + ContentType: "application/json", + } + require.NoError(t, db.Create(event).Error) + assert.NotEmpty(t, event.ID) + + // Verify we can read it back + var readEvent Event + require.NoError(t, db.First(&readEvent, "id = ?", event.ID).Error) + assert.Equal(t, webhookID, readEvent.WebhookID) + assert.Equal(t, "POST", readEvent.Method) + assert.Equal(t, `{"test": true}`, readEvent.Body) +} + +func TestWebhookDBManager_DeleteDB(t *testing.T) { + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() + + // Create the DB and write some data + require.NoError(t, mgr.CreateDB(webhookID)) + db, err := mgr.GetDB(webhookID) + require.NoError(t, err) + + event := &Event{ + WebhookID: webhookID, + EntrypointID: uuid.New().String(), + Method: "POST", + Body: `{"test": true}`, + ContentType: "application/json", + } + require.NoError(t, db.Create(event).Error) + + // Delete the DB + require.NoError(t, mgr.DeleteDB(webhookID)) + + // File should no longer exist + assert.False(t, mgr.DBExists(webhookID)) + + // Verify the file is actually gone from disk + dbPath := mgr.dbPath(webhookID) + _, err = os.Stat(dbPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestWebhookDBManager_LazyCreation(t *testing.T) { + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() + + // GetDB should lazily create the database + db, err := mgr.GetDB(webhookID) + require.NoError(t, err) + require.NotNil(t, db) + + // File should now exist + assert.True(t, mgr.DBExists(webhookID)) +} + +func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) { + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() + targetID := uuid.New().String() + + db, err := mgr.GetDB(webhookID) + require.NoError(t, err) + + // Create an event + event := &Event{ + WebhookID: webhookID, + EntrypointID: uuid.New().String(), + Method: "POST", + Headers: `{"Content-Type":["application/json"]}`, + Body: `{"payload": "test"}`, + ContentType: "application/json", + } + require.NoError(t, db.Create(event).Error) + + // Create a delivery + delivery := &Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: DeliveryStatusPending, + } + require.NoError(t, db.Create(delivery).Error) + + // Query pending deliveries + var pending []Delivery + require.NoError(t, db.Where("status = ?", DeliveryStatusPending). + Preload("Event"). + Find(&pending).Error) + require.Len(t, pending, 1) + assert.Equal(t, event.ID, pending[0].EventID) + assert.Equal(t, "POST", pending[0].Event.Method) + + // Create a delivery result + result := &DeliveryResult{ + DeliveryID: delivery.ID, + AttemptNum: 1, + Success: true, + StatusCode: 200, + Duration: 42, + } + require.NoError(t, db.Create(result).Error) + + // Update delivery status + require.NoError(t, db.Model(delivery).Update("status", DeliveryStatusDelivered).Error) + + // Verify no more pending deliveries + var stillPending []Delivery + require.NoError(t, db.Where("status = ?", DeliveryStatusPending).Find(&stillPending).Error) + assert.Empty(t, stillPending) +} + +func TestWebhookDBManager_MultipleWebhooks(t *testing.T) { + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhook1 := uuid.New().String() + webhook2 := uuid.New().String() + + // Create DBs for two webhooks + require.NoError(t, mgr.CreateDB(webhook1)) + require.NoError(t, mgr.CreateDB(webhook2)) + + db1, err := mgr.GetDB(webhook1) + require.NoError(t, err) + db2, err := mgr.GetDB(webhook2) + require.NoError(t, err) + + // Write events to each webhook's DB + event1 := &Event{ + WebhookID: webhook1, + EntrypointID: uuid.New().String(), + Method: "POST", + Body: `{"webhook": 1}`, + ContentType: "application/json", + } + event2 := &Event{ + WebhookID: webhook2, + EntrypointID: uuid.New().String(), + Method: "PUT", + Body: `{"webhook": 2}`, + ContentType: "application/json", + } + require.NoError(t, db1.Create(event1).Error) + require.NoError(t, db2.Create(event2).Error) + + // Verify isolation: each DB only has its own events + var count1 int64 + db1.Model(&Event{}).Count(&count1) + assert.Equal(t, int64(1), count1) + + var count2 int64 + db2.Model(&Event{}).Count(&count2) + assert.Equal(t, int64(1), count2) + + // Delete webhook1's DB, webhook2 should be unaffected + require.NoError(t, mgr.DeleteDB(webhook1)) + assert.False(t, mgr.DBExists(webhook1)) + assert.True(t, mgr.DBExists(webhook2)) + + // webhook2's data should still be accessible + var events []Event + require.NoError(t, db2.Find(&events).Error) + assert.Len(t, events, 1) + assert.Equal(t, "PUT", events[0].Method) +} + +func TestWebhookDBManager_CloseAll(t *testing.T) { + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + + // Create a few DBs + for i := 0; i < 3; i++ { + require.NoError(t, mgr.CreateDB(uuid.New().String())) + } + + // CloseAll should close all connections without error + require.NoError(t, mgr.CloseAll()) + + // Stop lifecycle (CloseAll already called, but shouldn't panic) + require.NoError(t, lc.Stop(ctx)) +} diff --git a/internal/delivery/circuit_breaker.go b/internal/delivery/circuit_breaker.go new file mode 100644 index 0000000..f49a15b --- /dev/null +++ b/internal/delivery/circuit_breaker.go @@ -0,0 +1,162 @@ +package delivery + +import ( + "sync" + "time" +) + +// CircuitState represents the current state of a circuit breaker. +type CircuitState int + +const ( + // CircuitClosed is the normal operating state. Deliveries flow through. + CircuitClosed CircuitState = iota + // CircuitOpen means the circuit has tripped. Deliveries are skipped + // until the cooldown expires. + CircuitOpen + // CircuitHalfOpen allows a single probe delivery to test whether + // the target has recovered. + CircuitHalfOpen +) + +const ( + // defaultFailureThreshold is the number of consecutive failures + // before a circuit breaker trips open. + defaultFailureThreshold = 5 + + // defaultCooldown is how long a circuit stays open before + // transitioning to half-open for a probe delivery. + defaultCooldown = 30 * time.Second +) + +// CircuitBreaker implements the circuit breaker pattern for a single +// delivery target. It tracks consecutive failures and prevents +// hammering a down target by temporarily stopping delivery attempts. +// +// States: +// - Closed (normal): deliveries flow through; consecutive failures +// are counted. +// - Open (tripped): deliveries are skipped; a cooldown timer is +// running. After the cooldown expires the state moves to HalfOpen. +// - HalfOpen (probing): one probe delivery is allowed. If it +// succeeds the circuit closes; if it fails the circuit reopens. +type CircuitBreaker struct { + mu sync.Mutex + state CircuitState + failures int + threshold int + cooldown time.Duration + lastFailure time.Time +} + +// NewCircuitBreaker creates a circuit breaker with default settings. +func NewCircuitBreaker() *CircuitBreaker { + return &CircuitBreaker{ + state: CircuitClosed, + threshold: defaultFailureThreshold, + cooldown: defaultCooldown, + } +} + +// Allow checks whether a delivery attempt should proceed. It returns +// true if the delivery should be attempted, false if the circuit is +// open and the delivery should be skipped. +// +// When the circuit is open and the cooldown has elapsed, Allow +// transitions to half-open and permits exactly one probe delivery. +func (cb *CircuitBreaker) Allow() bool { + cb.mu.Lock() + defer cb.mu.Unlock() + + switch cb.state { + case CircuitClosed: + return true + + case CircuitOpen: + // Check if cooldown has elapsed + if time.Since(cb.lastFailure) >= cb.cooldown { + cb.state = CircuitHalfOpen + return true + } + return false + + case CircuitHalfOpen: + // Only one probe at a time — reject additional attempts while + // a probe is in flight. The probe goroutine will call + // RecordSuccess or RecordFailure to resolve the state. + return false + + default: + return true + } +} + +// CooldownRemaining returns how much time is left before an open circuit +// transitions to half-open. Returns zero if the circuit is not open or +// the cooldown has already elapsed. +func (cb *CircuitBreaker) CooldownRemaining() time.Duration { + cb.mu.Lock() + defer cb.mu.Unlock() + + if cb.state != CircuitOpen { + return 0 + } + + remaining := cb.cooldown - time.Since(cb.lastFailure) + if remaining < 0 { + return 0 + } + return remaining +} + +// RecordSuccess records a successful delivery and resets the circuit +// breaker to closed state with zero failures. +func (cb *CircuitBreaker) RecordSuccess() { + cb.mu.Lock() + defer cb.mu.Unlock() + + cb.failures = 0 + cb.state = CircuitClosed +} + +// RecordFailure records a failed delivery. If the failure count reaches +// the threshold, the circuit trips open. +func (cb *CircuitBreaker) RecordFailure() { + cb.mu.Lock() + defer cb.mu.Unlock() + + cb.failures++ + cb.lastFailure = time.Now() + + switch cb.state { + case CircuitClosed: + if cb.failures >= cb.threshold { + cb.state = CircuitOpen + } + + case CircuitHalfOpen: + // Probe failed — reopen immediately + cb.state = CircuitOpen + } +} + +// State returns the current circuit state. Safe for concurrent use. +func (cb *CircuitBreaker) State() CircuitState { + cb.mu.Lock() + defer cb.mu.Unlock() + return cb.state +} + +// String returns the human-readable name of a circuit state. +func (s CircuitState) String() string { + switch s { + case CircuitClosed: + return "closed" + case CircuitOpen: + return "open" + case CircuitHalfOpen: + return "half-open" + default: + return "unknown" + } +} diff --git a/internal/delivery/circuit_breaker_test.go b/internal/delivery/circuit_breaker_test.go new file mode 100644 index 0000000..4ea68da --- /dev/null +++ b/internal/delivery/circuit_breaker_test.go @@ -0,0 +1,243 @@ +package delivery + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCircuitBreaker_ClosedState_AllowsDeliveries(t *testing.T) { + t.Parallel() + cb := NewCircuitBreaker() + + assert.Equal(t, CircuitClosed, cb.State()) + assert.True(t, cb.Allow(), "closed circuit should allow deliveries") + // Multiple calls should all succeed + for i := 0; i < 10; i++ { + assert.True(t, cb.Allow()) + } +} + +func TestCircuitBreaker_FailureCounting(t *testing.T) { + t.Parallel() + cb := NewCircuitBreaker() + + // Record failures below threshold — circuit should stay closed + for i := 0; i < defaultFailureThreshold-1; i++ { + cb.RecordFailure() + assert.Equal(t, CircuitClosed, cb.State(), + "circuit should remain closed after %d failures", i+1) + assert.True(t, cb.Allow(), "should still allow after %d failures", i+1) + } +} + +func TestCircuitBreaker_OpenTransition(t *testing.T) { + t.Parallel() + cb := NewCircuitBreaker() + + // Record exactly threshold failures + for i := 0; i < defaultFailureThreshold; i++ { + cb.RecordFailure() + } + + assert.Equal(t, CircuitOpen, cb.State(), "circuit should be open after threshold failures") + assert.False(t, cb.Allow(), "open circuit should reject deliveries") +} + +func TestCircuitBreaker_Cooldown_StaysOpen(t *testing.T) { + t.Parallel() + // Use a circuit with a known short cooldown for testing + cb := &CircuitBreaker{ + state: CircuitClosed, + threshold: defaultFailureThreshold, + cooldown: 200 * time.Millisecond, + } + + // Trip the circuit open + for i := 0; i < defaultFailureThreshold; i++ { + cb.RecordFailure() + } + require.Equal(t, CircuitOpen, cb.State()) + + // During cooldown, Allow should return false + assert.False(t, cb.Allow(), "should be blocked during cooldown") + + // CooldownRemaining should be positive + remaining := cb.CooldownRemaining() + assert.Greater(t, remaining, time.Duration(0), "cooldown should have remaining time") +} + +func TestCircuitBreaker_HalfOpen_AfterCooldown(t *testing.T) { + t.Parallel() + cb := &CircuitBreaker{ + state: CircuitClosed, + threshold: defaultFailureThreshold, + cooldown: 50 * time.Millisecond, + } + + // Trip the circuit open + for i := 0; i < defaultFailureThreshold; i++ { + cb.RecordFailure() + } + require.Equal(t, CircuitOpen, cb.State()) + + // Wait for cooldown to expire + time.Sleep(60 * time.Millisecond) + + // CooldownRemaining should be zero after cooldown + assert.Equal(t, time.Duration(0), cb.CooldownRemaining()) + + // First Allow after cooldown should succeed (probe) + assert.True(t, cb.Allow(), "should allow one probe after cooldown") + assert.Equal(t, CircuitHalfOpen, cb.State(), "should be half-open after probe allowed") + + // Second Allow should be rejected (only one probe at a time) + assert.False(t, cb.Allow(), "should reject additional probes while half-open") +} + +func TestCircuitBreaker_ProbeSuccess_ClosesCircuit(t *testing.T) { + t.Parallel() + cb := &CircuitBreaker{ + state: CircuitClosed, + threshold: defaultFailureThreshold, + cooldown: 50 * time.Millisecond, + } + + // Trip open → wait for cooldown → allow probe + for i := 0; i < defaultFailureThreshold; i++ { + cb.RecordFailure() + } + time.Sleep(60 * time.Millisecond) + require.True(t, cb.Allow()) // probe allowed, state → half-open + + // Probe succeeds → circuit should close + cb.RecordSuccess() + assert.Equal(t, CircuitClosed, cb.State(), "successful probe should close circuit") + + // Should allow deliveries again + assert.True(t, cb.Allow(), "closed circuit should allow deliveries") +} + +func TestCircuitBreaker_ProbeFailure_ReopensCircuit(t *testing.T) { + t.Parallel() + cb := &CircuitBreaker{ + state: CircuitClosed, + threshold: defaultFailureThreshold, + cooldown: 50 * time.Millisecond, + } + + // Trip open → wait for cooldown → allow probe + for i := 0; i < defaultFailureThreshold; i++ { + cb.RecordFailure() + } + time.Sleep(60 * time.Millisecond) + require.True(t, cb.Allow()) // probe allowed, state → half-open + + // Probe fails → circuit should reopen + cb.RecordFailure() + assert.Equal(t, CircuitOpen, cb.State(), "failed probe should reopen circuit") + assert.False(t, cb.Allow(), "reopened circuit should reject deliveries") +} + +func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) { + t.Parallel() + cb := NewCircuitBreaker() + + // Accumulate failures just below threshold + for i := 0; i < defaultFailureThreshold-1; i++ { + cb.RecordFailure() + } + require.Equal(t, CircuitClosed, cb.State()) + + // Success should reset the failure counter + cb.RecordSuccess() + assert.Equal(t, CircuitClosed, cb.State()) + + // Now we should need another full threshold of failures to trip + for i := 0; i < defaultFailureThreshold-1; i++ { + cb.RecordFailure() + } + assert.Equal(t, CircuitClosed, cb.State(), + "circuit should still be closed — success reset the counter") + + // One more failure should trip it + cb.RecordFailure() + assert.Equal(t, CircuitOpen, cb.State()) +} + +func TestCircuitBreaker_ConcurrentAccess(t *testing.T) { + t.Parallel() + cb := NewCircuitBreaker() + + const goroutines = 100 + var wg sync.WaitGroup + wg.Add(goroutines * 3) + + // Concurrent Allow calls + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + cb.Allow() + }() + } + + // Concurrent RecordFailure calls + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + cb.RecordFailure() + }() + } + + // Concurrent RecordSuccess calls + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + cb.RecordSuccess() + }() + } + + wg.Wait() + // No panic or data race — the test passes if -race doesn't flag anything. + // State should be one of the valid states. + state := cb.State() + assert.Contains(t, []CircuitState{CircuitClosed, CircuitOpen, CircuitHalfOpen}, state, + "state should be valid after concurrent access") +} + +func TestCircuitBreaker_CooldownRemaining_ClosedReturnsZero(t *testing.T) { + t.Parallel() + cb := NewCircuitBreaker() + assert.Equal(t, time.Duration(0), cb.CooldownRemaining(), + "closed circuit should have zero cooldown remaining") +} + +func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsZero(t *testing.T) { + t.Parallel() + cb := &CircuitBreaker{ + state: CircuitClosed, + threshold: defaultFailureThreshold, + cooldown: 50 * time.Millisecond, + } + + // Trip open, wait, transition to half-open + for i := 0; i < defaultFailureThreshold; i++ { + cb.RecordFailure() + } + time.Sleep(60 * time.Millisecond) + require.True(t, cb.Allow()) // → half-open + + assert.Equal(t, time.Duration(0), cb.CooldownRemaining(), + "half-open circuit should have zero cooldown remaining") +} + +func TestCircuitState_String(t *testing.T) { + t.Parallel() + assert.Equal(t, "closed", CircuitClosed.String()) + assert.Equal(t, "open", CircuitOpen.String()) + assert.Equal(t, "half-open", CircuitHalfOpen.String()) + assert.Equal(t, "unknown", CircuitState(99).String()) +} diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go new file mode 100644 index 0000000..4a4a1a6 --- /dev/null +++ b/internal/delivery/engine.go @@ -0,0 +1,1100 @@ +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 ( + // deliveryChannelSize is the buffer size for the delivery channel. + // New DeliveryTasks from the webhook handler are sent here. Workers + // drain this channel. Sized large enough that the webhook handler + // should never block under normal load. + deliveryChannelSize = 10000 + + // retryChannelSize is the buffer size for the retry channel. + // Timer-fired retries are sent here for processing by workers. + retryChannelSize = 10000 + + // defaultWorkers is the number of worker goroutines in the delivery + // engine pool. At most this many deliveries are in-flight at any + // time, preventing goroutine explosions regardless of queue depth. + defaultWorkers = 10 + + // retrySweepInterval is how often the periodic retry sweep runs. + // The sweep scans all per-webhook databases for "orphaned" retrying + // deliveries — ones whose in-memory timer was dropped because the + // retry channel was full. This is the DB-mediated fallback path. + retrySweepInterval = 60 * time.Second + + // MaxInlineBodySize is the maximum event body size that will be carried + // inline in a DeliveryTask through the channel. Bodies at or above this + // size are left nil and fetched from the per-webhook database on demand. + // This keeps channel buffer memory bounded under high traffic. + MaxInlineBodySize = 16 * 1024 + + // 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 +) + +// DeliveryTask contains everything needed to deliver an event to a single +// target. In the ≤16KB happy path, Body is non-nil and the engine delivers +// without touching any database — it trusts that the webhook handler wrote +// the records correctly. Only after a delivery attempt (success or failure) +// does the engine write to the DB to record the result. +// +// When Body is nil (payload ≥ MaxInlineBodySize), the engine fetches the +// body from the per-webhook database using EventID before delivering. +type DeliveryTask struct { + DeliveryID string // ID of the Delivery record (for recording results) + EventID string // Event ID (for DB lookup if body is nil) + WebhookID string // Webhook ID (for per-webhook DB access) + + // Target info (from main DB, included at notification time) + TargetID string + TargetName string + TargetType database.TargetType + TargetConfig string // JSON config (URL, headers, etc.) + MaxRetries int + + // Event data (inline for ≤16KB bodies) + Method string + Headers string // JSON + ContentType string + Body *string // nil if body ≥ MaxInlineBodySize; fetch from DB by EventID + + // AttemptNum tracks the delivery attempt number. Set to 1 for the + // initial delivery and incremented for each retry. This avoids a DB + // query to count prior results in the hot path. + AttemptNum int +} + +// Notifier is the interface for notifying the delivery engine about new +// deliveries. Implemented by Engine and injected into handlers. +type Notifier interface { + Notify(tasks []DeliveryTask) +} + +// HTTPTargetConfig holds configuration for http 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 + DBManager *database.WebhookDBManager + Logger *logger.Logger +} + +// Engine processes queued deliveries in the background using a bounded +// worker pool architecture. New deliveries arrive as individual +// DeliveryTask values via a buffered delivery channel from the webhook +// handler. Failed deliveries that need retry are scheduled via Go timers +// with exponential backoff; each timer fires into a separate retry +// channel. A fixed number of worker goroutines drain both channels, +// ensuring at most N deliveries are in-flight at any time (N = number +// of workers). This prevents goroutine explosions when a circuit breaker +// is open for a long period and many retries queue up. +// +// In the happy path (body ≤ 16KB), a worker delivers without reading +// from any database — it only writes to record results. The database +// stores delivery status for crash recovery only; on startup the engine +// scans for interrupted deliveries and re-queues them into the channels. +type Engine struct { + database *database.Database + dbManager *database.WebhookDBManager + log *slog.Logger + client *http.Client + cancel context.CancelFunc + wg sync.WaitGroup + deliveryCh chan DeliveryTask + retryCh chan DeliveryTask + workers int + + // circuitBreakers stores a *CircuitBreaker per target ID. Only used + // for HTTP targets with MaxRetries > 0 — fire-and-forget HTTP targets + // (MaxRetries == 0), database targets, and log targets do not need + // circuit breakers because they either fire once or are local ops. + circuitBreakers sync.Map +} + +// New creates and registers the delivery engine with the fx lifecycle. +func New(lc fx.Lifecycle, params EngineParams) *Engine { + e := &Engine{ + database: params.DB, + dbManager: params.DBManager, + log: params.Logger.Get(), + client: &http.Client{ + Timeout: httpClientTimeout, + }, + deliveryCh: make(chan DeliveryTask, deliveryChannelSize), + retryCh: make(chan DeliveryTask, retryChannelSize), + workers: defaultWorkers, + } + + 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 + + // Start the worker pool. These are the ONLY goroutines that + // perform HTTP delivery. Bounded concurrency is guaranteed. + for i := 0; i < e.workers; i++ { + e.wg.Add(1) + go e.worker(ctx) + } + + // Start recovery scan in a separate goroutine. Recovered tasks + // are sent into the delivery/retry channels and picked up by workers. + e.wg.Add(1) + go e.recoverPending(ctx) + + // Start the periodic retry sweep. This is the DB-mediated fallback + // for retries whose timers were dropped due to channel overflow. + e.wg.Add(1) + go e.retrySweep(ctx) + + e.log.Info("delivery engine started", "workers", e.workers) +} + +func (e *Engine) stop() { + e.log.Info("delivery engine stopping") + e.cancel() + e.wg.Wait() + e.log.Info("delivery engine stopped") +} + +// Notify signals the delivery engine that new deliveries are ready. +// Called by the webhook handler after creating delivery records. Each +// DeliveryTask carries all data needed for delivery in the ≤16KB case. +// Tasks are sent individually to the delivery channel. The call is +// non-blocking; if the channel is full, a warning is logged and the +// delivery will be recovered on the next engine restart. +func (e *Engine) Notify(tasks []DeliveryTask) { + for i := range tasks { + select { + case e.deliveryCh <- tasks[i]: + default: + e.log.Warn("delivery channel full, task will be recovered on restart", + "delivery_id", tasks[i].DeliveryID, + "event_id", tasks[i].EventID, + ) + } + } +} + +// worker is the main loop for a worker goroutine. It selects from both +// the delivery channel (new tasks from the handler) and the retry channel +// (tasks from backoff timers). At most e.workers deliveries are in-flight +// at any time. +func (e *Engine) worker(ctx context.Context) { + defer e.wg.Done() + for { + select { + case <-ctx.Done(): + return + case task := <-e.deliveryCh: + e.processNewTask(ctx, &task) + case task := <-e.retryCh: + e.processRetryTask(ctx, &task) + } + } +} + +// recoverPending runs on startup to recover any pending or retrying +// deliveries that were interrupted by an unexpected shutdown. Recovered +// tasks are sent into the delivery/retry channels for workers to pick up. +func (e *Engine) recoverPending(ctx context.Context) { + defer e.wg.Done() + e.recoverInFlight(ctx) +} + +// processNewTask handles a single new delivery task from the delivery +// channel. It builds the event and target context from the task's inline +// data and executes the delivery. For large bodies (≥ MaxInlineBodySize), +// the body is fetched from the per-webhook database on demand. +func (e *Engine) processNewTask(ctx context.Context, task *DeliveryTask) { + webhookDB, err := e.dbManager.GetDB(task.WebhookID) + if err != nil { + e.log.Error("failed to get webhook database", + "webhook_id", task.WebhookID, + "error", err, + ) + return + } + + // Build Event from task data + event := database.Event{ + Method: task.Method, + Headers: task.Headers, + ContentType: task.ContentType, + } + event.ID = task.EventID + event.WebhookID = task.WebhookID + + if task.Body != nil { + event.Body = *task.Body + } else { + // Large body: fetch from per-webhook DB + var dbEvent database.Event + if err := webhookDB.Select("body"). + First(&dbEvent, "id = ?", task.EventID).Error; err != nil { + e.log.Error("failed to fetch event body from database", + "event_id", task.EventID, + "error", err, + ) + return + } + event.Body = dbEvent.Body + } + + // Build Target from task data (no main DB query needed) + target := database.Target{ + Name: task.TargetName, + Type: task.TargetType, + Config: task.TargetConfig, + MaxRetries: task.MaxRetries, + } + target.ID = task.TargetID + + // Build Delivery struct for the processing chain + d := &database.Delivery{ + EventID: task.EventID, + TargetID: task.TargetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: target, + } + d.ID = task.DeliveryID + + e.processDelivery(ctx, webhookDB, d, task) +} + +// processRetryTask handles a single delivery task fired by a retry timer. +// The task carries all data needed for delivery (same as the initial +// notification). The only DB read is a status check to verify the delivery +// hasn't been cancelled or resolved while the timer was pending. +func (e *Engine) processRetryTask(ctx context.Context, task *DeliveryTask) { + webhookDB, err := e.dbManager.GetDB(task.WebhookID) + if err != nil { + e.log.Error("failed to get webhook database for retry", + "webhook_id", task.WebhookID, + "delivery_id", task.DeliveryID, + "error", err, + ) + return + } + + // Verify delivery is still in retrying status (may have been + // cancelled or manually resolved while the timer was pending) + var d database.Delivery + if err := webhookDB.Select("id", "status"). + First(&d, "id = ?", task.DeliveryID).Error; err != nil { + e.log.Error("failed to load delivery for retry", + "delivery_id", task.DeliveryID, + "error", err, + ) + return + } + + if d.Status != database.DeliveryStatusRetrying { + e.log.Debug("skipping retry for delivery no longer in retrying status", + "delivery_id", d.ID, + "status", d.Status, + ) + return + } + + // Build Event from task data + event := database.Event{ + Method: task.Method, + Headers: task.Headers, + ContentType: task.ContentType, + } + event.ID = task.EventID + event.WebhookID = task.WebhookID + + if task.Body != nil { + event.Body = *task.Body + } else { + // Large body: fetch from per-webhook DB + var dbEvent database.Event + if err := webhookDB.Select("body"). + First(&dbEvent, "id = ?", task.EventID).Error; err != nil { + e.log.Error("failed to fetch event body for retry", + "event_id", task.EventID, + "error", err, + ) + return + } + event.Body = dbEvent.Body + } + + // Build Target from task data + target := database.Target{ + Name: task.TargetName, + Type: task.TargetType, + Config: task.TargetConfig, + MaxRetries: task.MaxRetries, + } + target.ID = task.TargetID + + // Populate the delivery with event and target for processing + d.EventID = task.EventID + d.TargetID = task.TargetID + d.Event = event + d.Target = target + + e.processDelivery(ctx, webhookDB, &d, task) +} + +// recoverInFlight scans all webhooks on startup for deliveries that were +// interrupted by an unexpected shutdown. Pending deliveries are sent to +// the delivery channel; retrying deliveries get timers scheduled for +// their remaining backoff period. +func (e *Engine) recoverInFlight(ctx context.Context) { + var webhookIDs []string + if err := e.database.DB().Model(&database.Webhook{}).Pluck("id", &webhookIDs).Error; err != nil { + e.log.Error("failed to query webhook IDs for recovery", "error", err) + return + } + + for _, webhookID := range webhookIDs { + select { + case <-ctx.Done(): + return + default: + } + + if !e.dbManager.DBExists(webhookID) { + continue + } + + e.recoverWebhookDeliveries(ctx, webhookID) + } +} + +// recoverWebhookDeliveries recovers pending and retrying deliveries for +// a single webhook. Pending deliveries are sent to the delivery channel; +// retrying deliveries get timers scheduled for their remaining backoff. +func (e *Engine) recoverWebhookDeliveries(ctx context.Context, webhookID string) { + webhookDB, err := e.dbManager.GetDB(webhookID) + if err != nil { + e.log.Error("failed to get webhook database for recovery", + "webhook_id", webhookID, + "error", err, + ) + return + } + + // Recover pending deliveries by sending them to the delivery channel + e.recoverPendingDeliveries(ctx, webhookDB, webhookID) + + // Schedule timers for retrying deliveries based on remaining backoff + var retrying []database.Delivery + if err := webhookDB.Where("status = ?", database.DeliveryStatusRetrying). + Find(&retrying).Error; err != nil { + e.log.Error("failed to query retrying deliveries for recovery", + "webhook_id", webhookID, + "error", err, + ) + return + } + + for i := range retrying { + d := &retrying[i] + + var resultCount int64 + webhookDB.Model(&database.DeliveryResult{}). + Where("delivery_id = ?", d.ID). + Count(&resultCount) + attemptNum := int(resultCount) + + // Load event for this delivery + var event database.Event + if err := webhookDB.First(&event, "id = ?", d.EventID).Error; err != nil { + e.log.Error("failed to load event for retrying delivery recovery", + "delivery_id", d.ID, + "event_id", d.EventID, + "error", err, + ) + continue + } + + // Load target from main DB + var target database.Target + if err := e.database.DB().First(&target, "id = ?", d.TargetID).Error; err != nil { + e.log.Error("failed to load target for retrying delivery recovery", + "delivery_id", d.ID, + "target_id", d.TargetID, + "error", err, + ) + continue + } + + // Calculate remaining backoff from last attempt + remaining := time.Duration(0) + + var lastResult database.DeliveryResult + if err := webhookDB.Where("delivery_id = ?", d.ID). + Order("created_at DESC"). + First(&lastResult).Error; err == nil { + shift := attemptNum - 1 + if shift < 0 { + shift = 0 + } + if shift > 30 { + shift = 30 + } + backoff := time.Duration(1< 30 { + shift = 30 + } + backoff := time.Duration(1<= 200 && statusCode < 300 + errMsg := "" + if reqErr != nil { + errMsg = reqErr.Error() + } + + e.recordResult(webhookDB, d, 1, success, statusCode, respBody, errMsg, duration) + + if success { + e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusDelivered) + } else { + e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed) + } + return + } + + // Retry mode: max_retries > 0 — use circuit breaker and exponential backoff. + + // Check the circuit breaker for this target before attempting delivery. + cb := e.getCircuitBreaker(task.TargetID) + if !cb.Allow() { + // Circuit is open — skip delivery, mark as retrying, and + // schedule a retry for after the cooldown expires. + remaining := cb.CooldownRemaining() + e.log.Info("circuit breaker open, skipping delivery", + "target_id", task.TargetID, + "target_name", task.TargetName, + "delivery_id", d.ID, + "cooldown_remaining", remaining, + ) + e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusRetrying) + + retryTask := *task + // Don't increment AttemptNum — this wasn't a real attempt + e.scheduleRetry(retryTask, remaining) + return + } + + attemptNum := task.AttemptNum + + // Attempt delivery immediately — backoff is handled by the timer + // that triggered this call, not by polling. + statusCode, respBody, duration, reqErr := e.doHTTPRequest(cfg, &d.Event) + + success := reqErr == nil && statusCode >= 200 && statusCode < 300 + errMsg := "" + if reqErr != nil { + errMsg = reqErr.Error() + } + + e.recordResult(webhookDB, d, attemptNum, success, statusCode, respBody, errMsg, duration) + + if success { + cb.RecordSuccess() + e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusDelivered) + return + } + + // Delivery failed — record failure in circuit breaker + cb.RecordFailure() + + if attemptNum >= maxRetries { + e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed) + } else { + e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusRetrying) + + // Schedule a timer for the next retry with exponential backoff. + // The timer fires a DeliveryTask into the retry channel carrying + // all data needed for the next attempt. + shift := attemptNum - 1 + if shift > 30 { + shift = 30 + } + backoff := time.Duration(1< 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(webhookDB *gorm.DB, 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 := webhookDB.Create(result).Error; err != nil { + e.log.Error("failed to record delivery result", + "delivery_id", d.ID, + "error", err, + ) + } +} + +func (e *Engine) updateDeliveryStatus(webhookDB *gorm.DB, d *database.Delivery, status database.DeliveryStatus) { + if err := webhookDB.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] +} diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go new file mode 100644 index 0000000..3e5c481 --- /dev/null +++ b/internal/delivery/engine_test.go @@ -0,0 +1,936 @@ +package delivery + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + _ "modernc.org/sqlite" + "sneak.berlin/go/webhooker/internal/database" +) + +// testWebhookDB creates a real SQLite per-webhook database in a temp dir +// and runs the event-tier migrations (Event, Delivery, DeliveryResult). +func testWebhookDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "events-test.db") + dsn := fmt.Sprintf("file:%s?cache=shared&mode=rwc", dbPath) + + sqlDB, err := sql.Open("sqlite", dsn) + require.NoError(t, err) + t.Cleanup(func() { sqlDB.Close() }) + + db, err := gorm.Open(sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, db.AutoMigrate( + &database.Event{}, + &database.Delivery{}, + &database.DeliveryResult{}, + )) + + return db +} + +// testEngine builds an Engine with custom settings for testing. It does +// NOT call start() — callers control lifecycle for deterministic tests. +func testEngine(t *testing.T, workers int) *Engine { + t.Helper() + return &Engine{ + log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), + client: &http.Client{Timeout: 5 * time.Second}, + deliveryCh: make(chan DeliveryTask, deliveryChannelSize), + retryCh: make(chan DeliveryTask, retryChannelSize), + workers: workers, + } +} + +// newHTTPTargetConfig returns a JSON config for an HTTP target +// pointing at the given URL. +func newHTTPTargetConfig(url string) string { + cfg := HTTPTargetConfig{URL: url} + data, err := json.Marshal(cfg) + if err != nil { + panic("failed to marshal HTTPTargetConfig: " + err.Error()) + } + return string(data) +} + +// seedEvent inserts an event into the per-webhook DB and returns it. +func seedEvent(t *testing.T, db *gorm.DB, body string) database.Event { + t.Helper() + event := database.Event{ + WebhookID: uuid.New().String(), + EntrypointID: uuid.New().String(), + Method: "POST", + Headers: `{"Content-Type":["application/json"]}`, + Body: body, + ContentType: "application/json", + } + require.NoError(t, db.Create(&event).Error) + return event +} + +// seedDelivery inserts a delivery for an event + target and returns it. +func seedDelivery(t *testing.T, db *gorm.DB, eventID, targetID string, status database.DeliveryStatus) database.Delivery { + t.Helper() + d := database.Delivery{ + EventID: eventID, + TargetID: targetID, + Status: status, + } + require.NoError(t, db.Create(&d).Error) + return d +} + +// --- Tests --- + +func TestNotify_NonBlocking(t *testing.T) { + t.Parallel() + e := testEngine(t, 1) + + // Fill the delivery channel to capacity + for i := 0; i < deliveryChannelSize; i++ { + e.deliveryCh <- DeliveryTask{DeliveryID: fmt.Sprintf("fill-%d", i)} + } + + // Notify should NOT block even though channel is full + done := make(chan struct{}) + go func() { + e.Notify([]DeliveryTask{ + {DeliveryID: "overflow-1"}, + {DeliveryID: "overflow-2"}, + }) + close(done) + }() + + select { + case <-done: + // success: Notify returned without blocking + case <-time.After(2 * time.Second): + t.Fatal("Notify blocked when delivery channel was full") + } +} + +func TestDeliverHTTP_Success(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + + var received atomic.Bool + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received.Store(true) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"ok":true}`) + })) + defer ts.Close() + + e := testEngine(t, 1) + targetID := uuid.New().String() + + event := seedEvent(t, db, `{"hello":"world"}`) + delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) + + task := &DeliveryTask{ + DeliveryID: delivery.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: targetID, + TargetName: "test-http", + TargetType: database.TargetTypeHTTP, + TargetConfig: newHTTPTargetConfig(ts.URL), + MaxRetries: 0, + AttemptNum: 1, + } + + d := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-http", + Type: database.TargetTypeHTTP, + Config: newHTTPTargetConfig(ts.URL), + }, + } + d.ID = delivery.ID + + e.deliverHTTP(context.TODO(), db, d, task) + + assert.True(t, received.Load(), "HTTP target should have received request") + + // Check DB: delivery should be delivered + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusDelivered, updated.Status) + + // Check that a result was recorded + var result database.DeliveryResult + require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error) + assert.True(t, result.Success) + assert.Equal(t, http.StatusOK, result.StatusCode) +} + +func TestDeliverHTTP_Failure(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "internal error") + })) + defer ts.Close() + + e := testEngine(t, 1) + targetID := uuid.New().String() + + event := seedEvent(t, db, `{"test":true}`) + delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) + + task := &DeliveryTask{ + DeliveryID: delivery.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: targetID, + TargetName: "test-http-fail", + TargetType: database.TargetTypeHTTP, + TargetConfig: newHTTPTargetConfig(ts.URL), + MaxRetries: 0, + AttemptNum: 1, + } + + d := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-http-fail", + Type: database.TargetTypeHTTP, + Config: newHTTPTargetConfig(ts.URL), + }, + } + d.ID = delivery.ID + + e.deliverHTTP(context.TODO(), db, d, task) + + // HTTP (fire-and-forget) marks as failed on non-2xx + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusFailed, updated.Status) + + var result database.DeliveryResult + require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error) + assert.False(t, result.Success) + assert.Equal(t, http.StatusInternalServerError, result.StatusCode) +} + +func TestDeliverDatabase_ImmediateSuccess(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + e := testEngine(t, 1) + + event := seedEvent(t, db, `{"db":"target"}`) + delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending) + + d := &database.Delivery{ + EventID: event.ID, + TargetID: delivery.TargetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-db", + Type: database.TargetTypeDatabase, + }, + } + d.ID = delivery.ID + + e.deliverDatabase(db, d) + + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusDelivered, updated.Status, + "database target should immediately succeed") + + var result database.DeliveryResult + require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error) + assert.True(t, result.Success) + assert.Equal(t, 0, result.StatusCode, "database target should not have an HTTP status code") +} + +func TestDeliverLog_ImmediateSuccess(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + e := testEngine(t, 1) + + event := seedEvent(t, db, `{"log":"target"}`) + delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending) + + d := &database.Delivery{ + EventID: event.ID, + TargetID: delivery.TargetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-log", + Type: database.TargetTypeLog, + }, + } + d.ID = delivery.ID + + e.deliverLog(db, d) + + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusDelivered, updated.Status, + "log target should immediately succeed") + + var result database.DeliveryResult + require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error) + assert.True(t, result.Success) +} + +func TestDeliverHTTP_WithRetries_Success(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + e := testEngine(t, 1) + targetID := uuid.New().String() + + event := seedEvent(t, db, `{"retry":"ok"}`) + delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) + + task := &DeliveryTask{ + DeliveryID: delivery.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: targetID, + TargetName: "test-http-retry", + TargetType: database.TargetTypeHTTP, + TargetConfig: newHTTPTargetConfig(ts.URL), + MaxRetries: 5, + AttemptNum: 1, + } + + d := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-http-retry", + Type: database.TargetTypeHTTP, + Config: newHTTPTargetConfig(ts.URL), + MaxRetries: 5, + }, + } + d.ID = delivery.ID + d.Target.ID = targetID + + e.deliverHTTP(context.TODO(), db, d, task) + + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusDelivered, updated.Status) + + // Circuit breaker should have recorded success + cb := e.getCircuitBreaker(targetID) + assert.Equal(t, CircuitClosed, cb.State()) +} + +func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer ts.Close() + + e := testEngine(t, 1) + targetID := uuid.New().String() + + event := seedEvent(t, db, `{"retry":"exhaust"}`) + delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusRetrying) + + maxRetries := 3 + task := &DeliveryTask{ + DeliveryID: delivery.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: targetID, + TargetName: "test-http-exhaust", + TargetType: database.TargetTypeHTTP, + TargetConfig: newHTTPTargetConfig(ts.URL), + MaxRetries: maxRetries, + AttemptNum: maxRetries, // final attempt + } + + d := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusRetrying, + Event: event, + Target: database.Target{ + Name: "test-http-exhaust", + Type: database.TargetTypeHTTP, + Config: newHTTPTargetConfig(ts.URL), + MaxRetries: maxRetries, + }, + } + d.ID = delivery.ID + d.Target.ID = targetID + + e.deliverHTTP(context.TODO(), db, d, task) + + // After max retries exhausted, delivery should be failed + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusFailed, updated.Status, + "delivery should be failed after max retries exhausted") +} + +func TestDeliverHTTP_SchedulesRetryOnFailure(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer ts.Close() + + e := testEngine(t, 1) + targetID := uuid.New().String() + + event := seedEvent(t, db, `{"retry":"schedule"}`) + delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) + + task := &DeliveryTask{ + DeliveryID: delivery.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: targetID, + TargetName: "test-http-schedule", + TargetType: database.TargetTypeHTTP, + TargetConfig: newHTTPTargetConfig(ts.URL), + MaxRetries: 5, + AttemptNum: 1, + } + + d := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-http-schedule", + Type: database.TargetTypeHTTP, + Config: newHTTPTargetConfig(ts.URL), + MaxRetries: 5, + }, + } + d.ID = delivery.ID + d.Target.ID = targetID + + e.deliverHTTP(context.TODO(), db, d, task) + + // Delivery should be in retrying status (not failed — retries remain) + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusRetrying, updated.Status, + "delivery should be retrying when retries remain") + + // The timer should fire a task into the retry channel. Wait briefly + // for the timer (backoff for attempt 1 is 1s, but we're just verifying + // the status was set correctly and a result was recorded). + var result database.DeliveryResult + require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error) + assert.False(t, result.Success) + assert.Equal(t, 1, result.AttemptNum) +} + +func TestExponentialBackoff_Durations(t *testing.T) { + t.Parallel() + // The engine uses: backoff = 2^(attemptNum-1) seconds + // attempt 1 → shift=0 → 1s + // attempt 2 → shift=1 → 2s + // attempt 3 → shift=2 → 4s + // attempt 4 → shift=3 → 8s + // attempt 5 → shift=4 → 16s + + expected := []time.Duration{ + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, + 8 * time.Second, + 16 * time.Second, + } + + for attemptNum := 1; attemptNum <= 5; attemptNum++ { + shift := attemptNum - 1 + if shift > 30 { + shift = 30 + } + backoff := time.Duration(1< 30 { + shift = 30 + } + backoff := time.Duration(1< maxSeen { + maxSeen = concurrent + } + mu.Unlock() + + time.Sleep(100 * time.Millisecond) // simulate slow target + + mu.Lock() + concurrent-- + mu.Unlock() + + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + e := testEngine(t, numWorkers) + // We need a minimal dbManager-like setup. Since processNewTask + // needs dbManager, we'll drive workers by sending tasks through + // the delivery channel and manually calling deliverHTTP instead. + // Instead, let's directly test the worker pool by creating tasks + // and processing them through the channel. + + // Create tasks for more work than workers + const numTasks = 10 + tasks := make([]database.Delivery, numTasks) + targetCfg := newHTTPTargetConfig(ts.URL) + + for i := 0; i < numTasks; i++ { + event := seedEvent(t, db, fmt.Sprintf(`{"task":%d}`, i)) + delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending) + tasks[i] = database.Delivery{ + EventID: event.ID, + TargetID: delivery.TargetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: fmt.Sprintf("task-%d", i), + Type: database.TargetTypeHTTP, + Config: targetCfg, + }, + } + tasks[i].ID = delivery.ID + } + + // Build DeliveryTask structs for each delivery (needed by deliverHTTP) + deliveryTasks := make([]DeliveryTask, numTasks) + for i := 0; i < numTasks; i++ { + deliveryTasks[i] = DeliveryTask{ + DeliveryID: tasks[i].ID, + EventID: tasks[i].EventID, + TargetID: tasks[i].TargetID, + TargetName: tasks[i].Target.Name, + TargetType: tasks[i].Target.Type, + TargetConfig: tasks[i].Target.Config, + MaxRetries: 0, + AttemptNum: 1, + } + } + + // Process all tasks through a bounded pool of goroutines to simulate + // the engine's worker pool behavior + var wg sync.WaitGroup + taskCh := make(chan int, numTasks) + for i := 0; i < numTasks; i++ { + taskCh <- i + } + close(taskCh) + + // Start exactly numWorkers goroutines + for w := 0; w < numWorkers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for idx := range taskCh { + e.deliverHTTP(context.TODO(), db, &tasks[idx], &deliveryTasks[idx]) + } + }() + } + + wg.Wait() + + mu.Lock() + observedMax := maxSeen + mu.Unlock() + + assert.LessOrEqual(t, observedMax, numWorkers, + "should never exceed %d concurrent deliveries, saw %d", numWorkers, observedMax) + + // All deliveries should be completed + for i := 0; i < numTasks; i++ { + var d database.Delivery + require.NoError(t, db.First(&d, "id = ?", tasks[i].ID).Error) + assert.Equal(t, database.DeliveryStatusDelivered, d.Status, + "task %d should be delivered", i) + } +} + +func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + e := testEngine(t, 1) + targetID := uuid.New().String() + + // Pre-trip the circuit breaker for this target + cb := e.getCircuitBreaker(targetID) + for i := 0; i < defaultFailureThreshold; i++ { + cb.RecordFailure() + } + require.Equal(t, CircuitOpen, cb.State()) + + event := seedEvent(t, db, `{"cb":"blocked"}`) + delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) + + task := &DeliveryTask{ + DeliveryID: delivery.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: targetID, + TargetName: "test-cb-block", + TargetType: database.TargetTypeHTTP, + TargetConfig: newHTTPTargetConfig("http://will-not-be-called.invalid"), + MaxRetries: 5, + AttemptNum: 1, + } + + d := &database.Delivery{ + EventID: event.ID, + TargetID: targetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-cb-block", + Type: database.TargetTypeHTTP, + Config: newHTTPTargetConfig("http://will-not-be-called.invalid"), + MaxRetries: 5, + }, + } + d.ID = delivery.ID + d.Target.ID = targetID + + e.deliverHTTP(context.TODO(), db, d, task) + + // Delivery should be retrying (circuit open, no attempt made) + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, database.DeliveryStatusRetrying, updated.Status, + "delivery should be retrying when circuit breaker is open") + + // No delivery result should have been recorded (no attempt was made) + var resultCount int64 + db.Model(&database.DeliveryResult{}).Where("delivery_id = ?", delivery.ID).Count(&resultCount) + assert.Equal(t, int64(0), resultCount, + "no delivery result should be recorded when circuit is open") +} + +func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) { + t.Parallel() + e := testEngine(t, 1) + + targetID := uuid.New().String() + cb1 := e.getCircuitBreaker(targetID) + require.NotNil(t, cb1) + assert.Equal(t, CircuitClosed, cb1.State()) + + // Same target should return the same circuit breaker + cb2 := e.getCircuitBreaker(targetID) + assert.Same(t, cb1, cb2, "same target ID should return the same circuit breaker") + + // Different target should return a different circuit breaker + otherID := uuid.New().String() + cb3 := e.getCircuitBreaker(otherID) + assert.NotSame(t, cb1, cb3, "different target ID should return a different circuit breaker") +} + +func TestParseHTTPConfig_Valid(t *testing.T) { + t.Parallel() + e := testEngine(t, 1) + + cfg, err := e.parseHTTPConfig(`{"url":"https://example.com/hook","headers":{"X-Token":"secret"}}`) + require.NoError(t, err) + assert.Equal(t, "https://example.com/hook", cfg.URL) + assert.Equal(t, "secret", cfg.Headers["X-Token"]) +} + +func TestParseHTTPConfig_Empty(t *testing.T) { + t.Parallel() + e := testEngine(t, 1) + + _, err := e.parseHTTPConfig("") + assert.Error(t, err, "empty config should return error") +} + +func TestParseHTTPConfig_MissingURL(t *testing.T) { + t.Parallel() + e := testEngine(t, 1) + + _, err := e.parseHTTPConfig(`{"headers":{"X-Token":"secret"}}`) + assert.Error(t, err, "config without URL should return error") +} + +func TestScheduleRetry_SendsToRetryChannel(t *testing.T) { + t.Parallel() + e := testEngine(t, 1) + + task := DeliveryTask{ + DeliveryID: uuid.New().String(), + EventID: uuid.New().String(), + WebhookID: uuid.New().String(), + TargetID: uuid.New().String(), + AttemptNum: 2, + } + + e.scheduleRetry(task, 10*time.Millisecond) + + // Wait for the timer to fire + select { + case received := <-e.retryCh: + assert.Equal(t, task.DeliveryID, received.DeliveryID) + assert.Equal(t, task.AttemptNum, received.AttemptNum) + case <-time.After(2 * time.Second): + t.Fatal("retry task was not sent to retry channel within timeout") + } +} + +func TestScheduleRetry_DropsWhenChannelFull(t *testing.T) { + t.Parallel() + e := &Engine{ + log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), + retryCh: make(chan DeliveryTask, 1), // tiny buffer + } + + // Fill the retry channel + e.retryCh <- DeliveryTask{DeliveryID: "fill"} + + task := DeliveryTask{ + DeliveryID: "overflow", + AttemptNum: 2, + } + + // Should not panic or block + e.scheduleRetry(task, 0) + + // Give timer a moment to fire + time.Sleep(50 * time.Millisecond) + + // Only the original task should be in the channel + received := <-e.retryCh + assert.Equal(t, "fill", received.DeliveryID, + "only the original task should be in the channel (overflow was dropped)") +} + +func TestIsForwardableHeader(t *testing.T) { + t.Parallel() + // Should forward + assert.True(t, isForwardableHeader("X-Custom-Header")) + assert.True(t, isForwardableHeader("Authorization")) + assert.True(t, isForwardableHeader("Accept")) + assert.True(t, isForwardableHeader("X-GitHub-Event")) + + // Should NOT forward (hop-by-hop) + assert.False(t, isForwardableHeader("Host")) + assert.False(t, isForwardableHeader("Connection")) + assert.False(t, isForwardableHeader("Keep-Alive")) + assert.False(t, isForwardableHeader("Transfer-Encoding")) + assert.False(t, isForwardableHeader("Content-Length")) +} + +func TestTruncate(t *testing.T) { + t.Parallel() + assert.Equal(t, "hello", truncate("hello", 10)) + assert.Equal(t, "hello", truncate("hello", 5)) + assert.Equal(t, "hel", truncate("hello", 3)) + assert.Equal(t, "", truncate("", 5)) +} + +func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) { + t.Parallel() + + var receivedHeaders http.Header + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + e := testEngine(t, 1) + cfg := &HTTPTargetConfig{ + URL: ts.URL, + Headers: map[string]string{"X-Target-Auth": "bearer xyz"}, + } + + event := &database.Event{ + Method: "POST", + Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`, + Body: `{"test":true}`, + ContentType: "application/json", + } + + statusCode, _, _, err := e.doHTTPRequest(cfg, event) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + + // Check forwarded headers + assert.Equal(t, "value1", receivedHeaders.Get("X-Custom")) + assert.Equal(t, "bearer xyz", receivedHeaders.Get("X-Target-Auth")) + assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type")) + assert.Equal(t, "webhooker/1.0", receivedHeaders.Get("User-Agent")) +} + +func TestProcessDelivery_RoutesToCorrectHandler(t *testing.T) { + t.Parallel() + db := testWebhookDB(t) + e := testEngine(t, 1) + + tests := []struct { + name string + targetType database.TargetType + wantStatus database.DeliveryStatus + }{ + {"database target", database.TargetTypeDatabase, database.DeliveryStatusDelivered}, + {"log target", database.TargetTypeLog, database.DeliveryStatusDelivered}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + event := seedEvent(t, db, `{"routing":"test"}`) + delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending) + + d := &database.Delivery{ + EventID: event.ID, + TargetID: delivery.TargetID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: "test-" + string(tt.targetType), + Type: tt.targetType, + }, + } + d.ID = delivery.ID + + task := &DeliveryTask{ + DeliveryID: delivery.ID, + TargetType: tt.targetType, + } + + e.processDelivery(context.TODO(), db, d, task) + + var updated database.Delivery + require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error) + assert.Equal(t, tt.wantStatus, updated.Status) + }) + } +} + +func TestMaxInlineBodySize_Constant(t *testing.T) { + t.Parallel() + // Verify the constant is 16KB as documented + assert.Equal(t, 16*1024, MaxInlineBodySize, + "MaxInlineBodySize should be 16KB (16384 bytes)") +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index b6809fc..e0b23bc 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -21,7 +21,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc { "Error": "", } - h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) + h.renderTemplate(w, r, "login.html", data) } } @@ -44,7 +44,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc { "Error": "Username and password are required", } w.WriteHeader(http.StatusBadRequest) - h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) + h.renderTemplate(w, r, "login.html", data) return } @@ -56,7 +56,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc { "Error": "Invalid username or password", } w.WriteHeader(http.StatusUnauthorized) - h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) + h.renderTemplate(w, r, "login.html", data) return } @@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc { "Error": "Invalid username or password", } w.WriteHeader(http.StatusUnauthorized) - h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) + h.renderTemplate(w, r, "login.html", data) return } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 0183668..625a12f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -9,28 +9,47 @@ import ( "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/session" + "sneak.berlin/go/webhooker/templates" ) // nolint:revive // HandlersParams is a standard fx naming convention type HandlersParams struct { fx.In - Logger *logger.Logger - Globals *globals.Globals - Database *database.Database - Healthcheck *healthcheck.Healthcheck - Session *session.Session + Logger *logger.Logger + Globals *globals.Globals + Database *database.Database + WebhookDBMgr *database.WebhookDBManager + Healthcheck *healthcheck.Healthcheck + Session *session.Session + Notifier delivery.Notifier } type Handlers struct { - params *HandlersParams - log *slog.Logger - hc *healthcheck.Healthcheck - db *database.Database - session *session.Session + params *HandlersParams + log *slog.Logger + hc *healthcheck.Healthcheck + db *database.Database + dbMgr *database.WebhookDBManager + session *session.Session + notifier delivery.Notifier + templates map[string]*template.Template +} + +// parsePageTemplate parses a page-specific template set from the embedded FS. +// Each page template is combined with the shared base, htmlheader, and navbar templates. +// The page file must be listed first so that its root action ({{template "base" .}}) +// becomes the template set's entry point. If a shared partial (e.g. htmlheader.html) +// is listed first, its {{define}} block becomes the root — which is empty — and +// Execute() produces no output. +func parsePageTemplate(pageFile string) *template.Template { + return template.Must( + template.ParseFS(templates.Templates, pageFile, "base.html", "htmlheader.html", "navbar.html"), + ) } func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { @@ -39,10 +58,24 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { s.log = params.Logger.Get() s.hc = params.Healthcheck s.db = params.Database + s.dbMgr = params.WebhookDBMgr s.session = params.Session + s.notifier = params.Notifier + + // Parse all page templates once at startup + s.templates = map[string]*template.Template{ + "index.html": parsePageTemplate("index.html"), + "login.html": parsePageTemplate("login.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{ OnStart: func(ctx context.Context) error { - // FIXME compile some templates here or something return nil }, }) @@ -66,30 +99,17 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac return json.NewDecoder(r.Body).Decode(v) } -// TemplateData represents the common data passed to templates -type TemplateData struct { - User *UserInfo - Version string - UserCount int64 - Uptime string -} - // UserInfo represents user information for templates type UserInfo struct { ID string Username string } -// renderTemplate renders a template with common data -func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templateFiles []string, data interface{}) { - // Always include the common templates - allTemplates := []string{"templates/htmlheader.html", "templates/navbar.html"} - allTemplates = append(allTemplates, templateFiles...) - - // Parse templates - tmpl, err := template.ParseFiles(allTemplates...) - if err != nil { - s.log.Error("failed to parse template", "error", err) +// renderTemplate renders a pre-parsed template with common data +func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) { + tmpl, ok := s.templates[pageTemplate] + if !ok { + s.log.Error("template not found", "template", pageTemplate) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -108,6 +128,16 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa } } + // If data is a map, merge user info into it + if m, ok := data.(map[string]interface{}); ok { + m["User"] = userInfo + if err := tmpl.Execute(w, m); err != nil { + s.log.Error("failed to execute template", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + // Wrap data with base template data type templateDataWrapper struct { User *UserInfo @@ -119,17 +149,6 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa Data: data, } - // If data is a map, merge user info into it - if m, ok := data.(map[string]interface{}); ok { - m["User"] = userInfo - if err := tmpl.Execute(w, m); err != nil { - s.log.Error("failed to execute template", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - } - return - } - - // Otherwise use wrapper if err := tmpl.Execute(w, wrapper); err != nil { s.log.Error("failed to execute template", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 83bf1dd..7f11f43 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -12,12 +12,18 @@ import ( "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/session" ) +// noopNotifier is a no-op delivery.Notifier for tests. +type noopNotifier struct{} + +func (n *noopNotifier) Notify([]delivery.DeliveryTask) {} + func TestHandleIndex(t *testing.T) { var h *Handlers @@ -28,17 +34,14 @@ func TestHandleIndex(t *testing.T) { logger.New, func() *config.Config { return &config.Config{ - // This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!" - SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=", + DataDir: t.TempDir(), } }, - func() *database.Database { - // Mock database with a mock DB method - db := &database.Database{} - return db - }, + database.New, + database.NewWebhookDBManager, healthcheck.New, session.New, + func() delivery.Notifier { return &noopNotifier{} }, New, ), fx.Populate(&h), @@ -62,16 +65,14 @@ func TestRenderTemplate(t *testing.T) { logger.New, func() *config.Config { return &config.Config{ - // This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!" - SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=", + DataDir: t.TempDir(), } }, - func() *database.Database { - // Mock database - return &database.Database{} - }, + database.New, + database.NewWebhookDBManager, healthcheck.New, session.New, + func() delivery.Notifier { return &noopNotifier{} }, New, ), fx.Populate(&h), @@ -87,10 +88,11 @@ func TestRenderTemplate(t *testing.T) { "Version": "1.0.0", } - // When templates don't exist, renderTemplate should return an error - h.renderTemplate(w, req, []string{"nonexistent.html"}, data) + // When a non-existent template name is requested, renderTemplate + // should return an internal server error + h.renderTemplate(w, req, "nonexistent.html", data) - // Should return internal server error when template parsing fails + // Should return internal server error when template is not found assert.Equal(t, http.StatusInternalServerError, w.Code) }) } diff --git a/internal/handlers/index.go b/internal/handlers/index.go index fb1a068..2dec0d3 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -8,11 +8,6 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -type IndexResponse struct { - Message string `json:"message"` - Version string `json:"version"` -} - func (s *Handlers) HandleIndex() http.HandlerFunc { // Calculate server start time startTime := time.Now() @@ -34,7 +29,7 @@ func (s *Handlers) HandleIndex() http.HandlerFunc { } // Render the template - s.renderTemplate(w, req, []string{"templates/base.html", "templates/index.html"}, data) + s.renderTemplate(w, req, "index.html", data) } } diff --git a/internal/handlers/profile.go b/internal/handlers/profile.go index 2a36f12..88458be 100644 --- a/internal/handlers/profile.go +++ b/internal/handlers/profile.go @@ -54,6 +54,6 @@ func (h *Handlers) HandleProfile() http.HandlerFunc { } // Render the profile page - h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data) + h.renderTemplate(w, r, "profile.html", data) } } diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index e8cd792..66a4873 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1,69 +1,721 @@ package handlers import ( + "encoding/json" "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 webhook sources +// 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 { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source list page - http.Error(w, "Not implemented", http.StatusNotImplemented) + userID, ok := h.getUserID(r) + 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) + + // Event count comes from per-webhook DB + if h.dbMgr.DBExists(webhooks[i].ID) { + if webhookDB, err := h.dbMgr.GetDB(webhooks[i].ID); err == nil { + webhookDB.Model(&database.Event{}).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 source +// HandleSourceCreate shows the form to create a new webhook. func (h *Handlers) HandleSourceCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source creation form - http.Error(w, "Not implemented", http.StatusNotImplemented) + data := map[string]interface{}{ + "Error": "", + } + h.renderTemplate(w, r, "sources_new.html", data) } } -// HandleSourceCreateSubmit handles the source creation form submission +// HandleSourceCreateSubmit handles the webhook creation form submission. func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source creation logic - http.Error(w, "Not implemented", http.StatusNotImplemented) + userID, ok := h.getUserID(r) + 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 + } + + // Create per-webhook event database + if err := h.dbMgr.CreateDB(webhook.ID); err != nil { + h.log.Error("failed to create webhook event database", + "webhook_id", webhook.ID, + "error", err, + ) + // Non-fatal: the DB will be created lazily on first event + } + + 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 source +// HandleSourceDetail shows details for a specific webhook. func (h *Handlers) HandleSourceDetail() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source detail page - http.Error(w, "Not implemented", http.StatusNotImplemented) + 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 + } + + 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 from per-webhook database + var events []database.Event + if h.dbMgr.DBExists(webhook.ID) { + if webhookDB, err := h.dbMgr.GetDB(webhook.ID); err == nil { + webhookDB.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 source +// HandleSourceEdit shows the form to edit a webhook. func (h *Handlers) HandleSourceEdit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source edit form - http.Error(w, "Not implemented", http.StatusNotImplemented) + 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 + } + + data := map[string]interface{}{ + "Webhook": webhook, + "Error": "", + } + h.renderTemplate(w, r, "source_edit.html", data) } } -// HandleSourceEditSubmit handles the source edit form submission +// HandleSourceEditSubmit handles the webhook edit form submission. func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source update logic - http.Error(w, "Not implemented", http.StatusNotImplemented) + 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") + 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 source deletion +// HandleSourceDelete handles webhook deletion. +// Configuration data is soft-deleted in the main DB. +// The per-webhook event database file is hard-deleted (permanently removed). func (h *Handlers) HandleSourceDelete() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source deletion logic - http.Error(w, "Not implemented", http.StatusNotImplemented) + 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 + } + + // Soft-delete configuration in the main application database + 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 entrypoints and targets (config tier) + tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Entrypoint{}) + tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Target{}) + 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 + } + + // Hard-delete the per-webhook event database file + if err := h.dbMgr.DeleteDB(webhook.ID); err != nil { + h.log.Error("failed to delete webhook event database", + "webhook_id", webhook.ID, + "error", err, + ) + // Non-fatal: file may not exist if no events were ever received + } + + 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 source +// HandleSourceLogs shows the request/response logs for a webhook. +// Events and deliveries are read from the per-webhook database. +// Target information is loaded from the main application database. func (h *Handlers) HandleSourceLogs() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // TODO: Implement source logs page - http.Error(w, "Not implemented", http.StatusNotImplemented) + 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 + } + + // Load targets from main DB for display + var targets []database.Target + h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&targets) + targetMap := make(map[string]database.Target, len(targets)) + for _, t := range targets { + targetMap[t.ID] = t + } + + // 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 + + // EventWithDeliveries holds an event with its associated deliveries + type EventWithDeliveries struct { + database.Event + Deliveries []database.Delivery + } + + var totalEvents int64 + var eventsWithDeliveries []EventWithDeliveries + + // Read events and deliveries from per-webhook database + if h.dbMgr.DBExists(webhook.ID) { + webhookDB, err := h.dbMgr.GetDB(webhook.ID) + if err != nil { + h.log.Error("failed to get webhook database", + "webhook_id", webhook.ID, + "error", err, + ) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + webhookDB.Model(&database.Event{}).Where("webhook_id = ?", webhook.ID).Count(&totalEvents) + + var events []database.Event + webhookDB.Where("webhook_id = ?", webhook.ID). + Order("created_at DESC"). + Offset(offset). + Limit(perPage). + Find(&events) + + eventsWithDeliveries = make([]EventWithDeliveries, len(events)) + for i := range events { + eventsWithDeliveries[i].Event = events[i] + // Load deliveries from per-webhook DB (without Target preload) + webhookDB.Where("event_id = ?", events[i].ID).Find(&eventsWithDeliveries[i].Deliveries) + // Manually assign targets from main DB + for j := range eventsWithDeliveries[i].Deliveries { + if target, ok := targetMap[eventsWithDeliveries[i].Deliveries[j].TargetID]; ok { + eventsWithDeliveries[i].Deliveries[j].Target = target + } + } + } + } + + 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.TargetTypeDatabase, database.TargetTypeLog: + // valid + default: + http.Error(w, "Invalid target type", http.StatusBadRequest) + return + } + + // Build config JSON for HTTP targets + var configJSON string + if targetType == database.TargetTypeHTTP { + 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 := 0 // default: fire-and-forget (no retries) + 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) + } +} + +// HandleEntrypointDelete handles deleting an individual entrypoint. +func (h *Handlers) HandleEntrypointDelete() 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") + entrypointID := chi.URLParam(r, "entrypointID") + + // Verify webhook 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 + } + + // Delete entrypoint (must belong to this webhook) + result := h.db.DB().Where("id = ? AND webhook_id = ?", entrypointID, webhook.ID).Delete(&database.Entrypoint{}) + if result.Error != nil { + h.log.Error("failed to delete entrypoint", "error", result.Error) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther) + } +} + +// HandleEntrypointToggle handles toggling the active state of an entrypoint. +func (h *Handlers) HandleEntrypointToggle() 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") + entrypointID := chi.URLParam(r, "entrypointID") + + // Verify webhook 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 + } + + // Find the entrypoint + var entrypoint database.Entrypoint + if err := h.db.DB().Where("id = ? AND webhook_id = ?", entrypointID, webhook.ID).First(&entrypoint).Error; err != nil { + http.NotFound(w, r) + return + } + + // Toggle active state + entrypoint.Active = !entrypoint.Active + if err := h.db.DB().Save(&entrypoint).Error; err != nil { + h.log.Error("failed to toggle entrypoint", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther) + } +} + +// HandleTargetDelete handles deleting an individual target. +func (h *Handlers) HandleTargetDelete() 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") + targetID := chi.URLParam(r, "targetID") + + // Verify webhook 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 + } + + // Delete target (must belong to this webhook) + result := h.db.DB().Where("id = ? AND webhook_id = ?", targetID, webhook.ID).Delete(&database.Target{}) + if result.Error != nil { + h.log.Error("failed to delete target", "error", result.Error) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther) + } +} + +// HandleTargetToggle handles toggling the active state of a target. +func (h *Handlers) HandleTargetToggle() 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") + targetID := chi.URLParam(r, "targetID") + + // Verify webhook 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 + } + + // Find the target + var target database.Target + if err := h.db.DB().Where("id = ? AND webhook_id = ?", targetID, webhook.ID).First(&target).Error; err != nil { + http.NotFound(w, r) + return + } + + // Toggle active state + target.Active = !target.Active + if err := h.db.DB().Save(&target).Error; err != nil { + h.log.Error("failed to toggle 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) +} diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index 474d2f8..dc3222d 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -1,41 +1,190 @@ package handlers import ( + "encoding/json" + "io" "net/http" "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" ) -// HandleWebhook handles incoming webhook requests +const ( + // maxWebhookBodySize is the maximum allowed webhook request body (1 MB). + maxWebhookBodySize = 1 << 20 +) + +// HandleWebhook handles incoming webhook requests at entrypoint URLs. +// Only POST requests are accepted; all other methods return 405 Method Not Allowed. +// Events and deliveries are stored in the per-webhook database. The handler +// builds self-contained DeliveryTask structs with all target and event data +// so the delivery engine can process them without additional DB reads. func (h *Handlers) HandleWebhook() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Get webhook UUID from URL - webhookUUID := chi.URLParam(r, "uuid") - if webhookUUID == "" { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + entrypointUUID := chi.URLParam(r, "uuid") + if entrypointUUID == "" { http.NotFound(w, r) return } - // Log the incoming webhook request h.log.Info("webhook request received", - "uuid", webhookUUID, + "entrypoint_uuid", entrypointUUID, "method", r.Method, "remote_addr", r.RemoteAddr, - "user_agent", r.UserAgent(), ) - // Only POST methods are allowed for webhooks - if r.Method != http.MethodPost { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + // Look up entrypoint by path (from main application DB) + var entrypoint database.Entrypoint + result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint) + if result.Error != nil { + h.log.Debug("entrypoint not found", "path", entrypointUUID) + http.NotFound(w, r) return } - // TODO: Implement webhook handling logic - // For now, return "unimplemented" for all webhook POST requests - w.WriteHeader(http.StatusNotFound) - _, err := w.Write([]byte("unimplemented")) + // Check if active + if !entrypoint.Active { + http.Error(w, "Gone", http.StatusGone) + return + } + + // Read body with size limit + body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) 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 + } + + // Find all active targets for this webhook (from main application DB) + var targets []database.Target + if targetErr := h.db.DB().Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; targetErr != nil { + h.log.Error("failed to query targets", "error", targetErr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Get the per-webhook database for event storage + webhookDB, err := h.dbMgr.GetDB(entrypoint.WebhookID) + if err != nil { + h.log.Error("failed to get webhook database", + "webhook_id", entrypoint.WebhookID, + "error", err, + ) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Create the event and deliveries in a transaction on the per-webhook DB + tx := webhookDB.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 + } + + // Prepare body pointer for inline transport (≤16KB bodies are + // included in the DeliveryTask so the engine needs no DB read). + var bodyPtr *string + if len(body) < delivery.MaxInlineBodySize { + bodyStr := string(body) + bodyPtr = &bodyStr + } + + // Create delivery records and build self-contained delivery tasks + tasks := make([]delivery.DeliveryTask, 0, len(targets)) + for i := range targets { + dlv := &database.Delivery{ + EventID: event.ID, + TargetID: targets[i].ID, + Status: database.DeliveryStatusPending, + } + if err := tx.Create(dlv).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 + } + + tasks = append(tasks, delivery.DeliveryTask{ + DeliveryID: dlv.ID, + EventID: event.ID, + WebhookID: entrypoint.WebhookID, + TargetID: targets[i].ID, + TargetName: targets[i].Name, + TargetType: targets[i].Type, + TargetConfig: targets[i].Config, + MaxRetries: targets[i].MaxRetries, + Method: event.Method, + Headers: event.Headers, + ContentType: event.ContentType, + Body: bodyPtr, + AttemptNum: 1, + }) + } + + 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 + } + + // Notify the delivery engine with self-contained delivery tasks. + // Each task carries all target config and event data inline so + // the engine can deliver without touching any database (in the + // ≤16KB happy path). The engine only writes to the DB to record + // delivery results after each attempt. + if len(tasks) > 0 { + h.notifier.Notify(tasks) + } + + 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) } } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 687b241..61ead7f 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -17,8 +17,9 @@ type LoggerParams struct { } type Logger struct { - logger *slog.Logger - params LoggerParams + logger *slog.Logger + levelVar *slog.LevelVar + params LoggerParams } // nolint:revive // lc parameter is required by fx even if unused @@ -26,24 +27,30 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) { l := new(Logger) l.params = params + // Use slog.LevelVar for dynamic log level changes + l.levelVar = new(slog.LevelVar) + l.levelVar.Set(slog.LevelInfo) + // Determine if we're running in a terminal tty := false if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { tty = true } + replaceAttr := func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused + // Always use UTC for timestamps + if a.Key == slog.TimeKey { + if t, ok := a.Value.Any().(time.Time); ok { + return slog.Time(slog.TimeKey, t.UTC()) + } + } + return a + } + var handler slog.Handler opts := &slog.HandlerOptions{ - Level: slog.LevelInfo, - ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused - // Always use UTC for timestamps - if a.Key == slog.TimeKey { - if t, ok := a.Value.Any().(time.Time); ok { - return slog.Time(slog.TimeKey, t.UTC()) - } - } - return a - }, + Level: l.levelVar, + ReplaceAttr: replaceAttr, } if tty { @@ -63,34 +70,7 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) { } func (l *Logger) EnableDebugLogging() { - // Recreate logger with debug level - tty := false - if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { - tty = true - } - - var handler slog.Handler - opts := &slog.HandlerOptions{ - Level: slog.LevelDebug, - ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused - // Always use UTC for timestamps - if a.Key == slog.TimeKey { - if t, ok := a.Value.Any().(time.Time); ok { - return slog.Time(slog.TimeKey, t.UTC()) - } - } - return a - }, - } - - if tty { - handler = slog.NewTextHandler(os.Stdout, opts) - } else { - handler = slog.NewJSONHandler(os.Stdout, opts) - } - - l.logger = slog.New(handler) - slog.SetDefault(l.logger) + l.levelVar.Set(slog.LevelDebug) l.logger.Debug("debug logging enabled", "debug", true) } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index abdba8c..4f96a63 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -16,6 +16,7 @@ import ( "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" + "sneak.berlin/go/webhooker/internal/session" ) // nolint:revive // MiddlewareParams is a standard fx naming convention @@ -24,17 +25,20 @@ type MiddlewareParams struct { Logger *logger.Logger Globals *globals.Globals Config *config.Config + Session *session.Session } type Middleware struct { - log *slog.Logger - params *MiddlewareParams + log *slog.Logger + params *MiddlewareParams + session *session.Session } func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) { s := new(Middleware) s.params = ¶ms s.log = params.Logger.Get() + s.session = params.Session return s, nil } @@ -104,25 +108,45 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler { } func (s *Middleware) CORS() func(http.Handler) http.Handler { - return cors.Handler(cors.Options{ - // CHANGEME! these are defaults, change them to suit your needs or - // read from environment/viper. - // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts - AllowedOrigins: []string{"*"}, - // AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, - ExposedHeaders: []string{"Link"}, - AllowCredentials: false, - MaxAge: 300, // Maximum value not ignored by any of major browsers - }) + if s.params.Config.IsDev() { + // In development, allow any origin for local testing. + return cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: false, + MaxAge: 300, + }) + } + // In production, the web UI is server-rendered so cross-origin + // requests are not expected. Return a no-op middleware. + return func(next http.Handler) http.Handler { + return next + } } -func (s *Middleware) Auth() func(http.Handler) http.Handler { +// RequireAuth returns middleware that checks for a valid session. +// Unauthenticated users are redirected to the login page. +func (s *Middleware) RequireAuth() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: implement proper authentication - s.log.Debug("AUTH: before request") + sess, err := s.session.Get(r) + if err != nil { + s.log.Debug("auth middleware: failed to get session", "error", err) + http.Redirect(w, r, "/pages/login", http.StatusSeeOther) + return + } + + if !s.session.IsAuthenticated(sess) { + s.log.Debug("auth middleware: unauthenticated request", + "path", r.URL.Path, + "method", r.Method, + ) + http.Redirect(w, r, "/pages/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) }) } diff --git a/internal/server/routes.go b/internal/server/routes.go index e9bb056..347b976 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -14,46 +14,29 @@ import ( func (s *Server) SetupRoutes() { s.router = chi.NewRouter() - // the mux .Use() takes a http.Handler wrapper func, like most - // things that deal with "middlewares" like alice et c, and will - // call ServeHTTP on it. These middlewares applied by the mux (you - // can .Use() more than one) will be applied to every request into - // the service. - + // Global middleware stack — applied to every request. s.router.Use(middleware.Recoverer) s.router.Use(middleware.RequestID) s.router.Use(s.mw.Logging()) - // add metrics middleware only if we can serve them behind auth + // Metrics middleware (only if credentials are configured) if s.params.Config.MetricsUsername != "" { s.router.Use(s.mw.Metrics()) } - // set up CORS headers s.router.Use(s.mw.CORS()) - - // timeout for request context; your handlers must finish within - // this window: s.router.Use(middleware.Timeout(60 * time.Second)) - // this adds a sentry reporting middleware if and only if sentry is - // enabled via setting of SENTRY_DSN in env. + // Sentry error reporting (if SENTRY_DSN is set). Repanic is true + // so panics still bubble up to the Recoverer middleware above. if s.sentryEnabled { - // Options docs at - // https://docs.sentry.io/platforms/go/guides/http/ - // we set sentry to repanic so that all panics bubble up to the - // Recoverer chi middleware above. sentryHandler := sentryhttp.New(sentryhttp.Options{ Repanic: true, }) s.router.Use(sentryHandler.Handle) } - //////////////////////////////////////////////////////////////////////// - // ROUTES - // complete docs: https://github.com/go-chi/chi - //////////////////////////////////////////////////////////////////////// - + // Routes s.router.Get("/", s.h.HandleIndex()) s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) @@ -90,23 +73,31 @@ func (s *Server) SetupRoutes() { r.Get("/", s.h.HandleProfile()) }) - // Webhook source management routes (require authentication) + // Webhook management routes (require authentication) s.router.Route("/sources", func(r chi.Router) { - // TODO: Add authentication middleware here - r.Get("/", s.h.HandleSourceList()) // List all sources + r.Use(s.mw.RequireAuth()) + r.Get("/", s.h.HandleSourceList()) // List all webhooks r.Get("/new", s.h.HandleSourceCreate()) // Show create form r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission }) s.router.Route("/source/{sourceID}", func(r chi.Router) { - // TODO: Add authentication middleware here - r.Get("/", s.h.HandleSourceDetail()) // View source details - r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form - r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission - r.Post("/delete", s.h.HandleSourceDelete()) // Delete source - r.Get("/logs", s.h.HandleSourceLogs()) // View source logs + r.Use(s.mw.RequireAuth()) + r.Get("/", s.h.HandleSourceDetail()) // View webhook details + r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form + r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission + r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook + r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs + r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint + r.Post("/entrypoints/{entrypointID}/delete", s.h.HandleEntrypointDelete()) // Delete entrypoint + r.Post("/entrypoints/{entrypointID}/toggle", s.h.HandleEntrypointToggle()) // Toggle entrypoint active + r.Post("/targets", s.h.HandleTargetCreate()) // Add target + r.Post("/targets/{targetID}/delete", s.h.HandleTargetDelete()) // Delete target + r.Post("/targets/{targetID}/toggle", s.h.HandleTargetToggle()) // Toggle target active }) - // Webhook endpoint - accepts all HTTP methods + // Entrypoint endpoint — accepts incoming webhook POST requests only. + // Using HandleFunc so the handler itself can return 405 for non-POST + // methods (chi's Method routing returns 405 without Allow header). s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook()) } diff --git a/internal/server/server.go b/internal/server/server.go index da94588..54a48e5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,19 +19,9 @@ import ( "github.com/getsentry/sentry-go" "github.com/go-chi/chi" - - // spooky action at a distance! - // this populates the environment - // from a ./.env file automatically - // for development configuration. - // .env contents should be things like - // `DBURL=postgres://user:pass@.../` - // (without the backticks, of course) - _ "github.com/joho/godotenv/autoload" ) -// ServerParams is a standard fx naming convention for dependency injection -// nolint:golint +// nolint:revive // ServerParams is a standard fx naming convention type ServerParams struct { fx.In Logger *logger.Logger @@ -118,7 +108,7 @@ func (s *Server) serve() int { s.log.Info("signal received", "signal", sig.String()) if s.cancelFunc != nil { // cancelling the main context will trigger a clean - // shutdown. + // shutdown via the fx OnStop hook. s.cancelFunc() } }() @@ -126,13 +116,13 @@ func (s *Server) serve() int { go s.serveUntilShutdown() <-s.ctx.Done() - s.cleanShutdown() + // Shutdown is handled by the fx OnStop hook (cleanShutdown). + // Do not call cleanShutdown() here to avoid a double invocation. return s.exitCode } func (s *Server) cleanupForExit() { s.log.Info("cleaning up") - // TODO: close database connections, flush buffers, etc. } func (s *Server) cleanShutdown() { diff --git a/internal/session/session.go b/internal/session/session.go index ed2a612..abb9e47 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1,6 +1,7 @@ package session import ( + "context" "encoding/base64" "fmt" "log/slog" @@ -9,6 +10,7 @@ import ( "github.com/gorilla/sessions" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/logger" ) @@ -29,8 +31,9 @@ const ( // nolint:revive // SessionParams is a standard fx naming convention type SessionParams struct { fx.In - Config *config.Config - Logger *logger.Logger + Config *config.Config + Database *database.Database + Logger *logger.Logger } // Session manages encrypted session storage @@ -40,39 +43,48 @@ type Session struct { config *config.Config } -// New creates a new session manager +// New creates a new session manager. The cookie store is initialized +// during the fx OnStart phase after the database is connected, using +// a session key that is auto-generated and stored in the database. func New(lc fx.Lifecycle, params SessionParams) (*Session, error) { - if params.Config.SessionKey == "" { - return nil, fmt.Errorf("SESSION_KEY environment variable is required") - } - - // Decode the base64 session key - keyBytes, err := base64.StdEncoding.DecodeString(params.Config.SessionKey) - if err != nil { - return nil, fmt.Errorf("invalid SESSION_KEY format: %w", err) - } - - if len(keyBytes) != 32 { - return nil, fmt.Errorf("SESSION_KEY must be 32 bytes (got %d)", len(keyBytes)) - } - - store := sessions.NewCookieStore(keyBytes) - - // Configure cookie options for security - store.Options = &sessions.Options{ - Path: "/", - MaxAge: 86400 * 7, // 7 days - HttpOnly: true, - Secure: !params.Config.IsDev(), // HTTPS in production - SameSite: http.SameSiteLaxMode, - } - s := &Session{ - store: store, log: params.Logger.Get(), config: params.Config, } + lc.Append(fx.Hook{ + OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx + sessionKey, err := params.Database.GetOrCreateSessionKey() + if err != nil { + return fmt.Errorf("failed to get session key: %w", err) + } + + keyBytes, err := base64.StdEncoding.DecodeString(sessionKey) + if err != nil { + return fmt.Errorf("invalid session key format: %w", err) + } + + if len(keyBytes) != 32 { + return fmt.Errorf("session key must be 32 bytes (got %d)", len(keyBytes)) + } + + store := sessions.NewCookieStore(keyBytes) + + // Configure cookie options for security + store.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, // 7 days + HttpOnly: true, + Secure: !params.Config.IsDev(), // HTTPS in production + SameSite: http.SameSiteLaxMode, + } + + s.store = store + s.log.Info("session manager initialized") + return nil + }, + }) + return s, nil } diff --git a/pkg/config/.gitignore b/pkg/config/.gitignore deleted file mode 100644 index 0519ecb..0000000 --- a/pkg/config/.gitignore +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/pkg/config/README.md b/pkg/config/README.md deleted file mode 100644 index c8081ab..0000000 --- a/pkg/config/README.md +++ /dev/null @@ -1,303 +0,0 @@ -# Configuration Module (Go) - -A simple, clean, and generic configuration management system that supports multiple environments and automatic value resolution. This module is completely standalone and can be used in any Go project. - -## Features - -- **Simple API**: Just `config.Get()` and `config.GetSecret()` -- **Type-safe helpers**: `config.GetString()`, `config.GetInt()`, `config.GetBool()` -- **Environment Support**: Separate configs for different environments (dev/prod/staging/etc) -- **Value Resolution**: Automatic resolution of special values: - - `$ENV:VARIABLE` - Read from environment variable - - `$GSM:secret-name` - Read from Google Secret Manager - - `$ASM:secret-name` - Read from AWS Secrets Manager - - `$FILE:/path/to/file` - Read from file contents -- **Hierarchical Defaults**: Environment-specific values override defaults -- **YAML-based**: Easy to read and edit configuration files -- **Thread-safe**: Safe for concurrent use -- **Testable**: Uses afero filesystem abstraction for easy testing -- **Minimal Dependencies**: Only requires YAML parser and cloud SDKs (optional) - -## Installation - -```bash -go get git.eeqj.de/sneak/webhooker/pkg/config -``` - -## Usage - -```go -package main - -import ( - "fmt" - "git.eeqj.de/sneak/webhooker/pkg/config" -) - -func main() { - // Set the environment explicitly - config.SetEnvironment("prod") - - // Get configuration values - baseURL := config.GetString("baseURL") - apiTimeout := config.GetInt("timeout", 30) - debugMode := config.GetBool("debugMode", false) - - // Get secret values - apiKey := config.GetSecretString("api_key") - dbPassword := config.GetSecretString("db_password", "default") - - // Get all values (for debugging) - allConfig := config.GetAllConfig() - allSecrets := config.GetAllSecrets() - - // Reload configuration from file - if err := config.Reload(); err != nil { - fmt.Printf("Failed to reload config: %v\n", err) - } -} -``` - -## Configuration File Structure - -Create a `config.yaml` file in your project root: - -```yaml -environments: - dev: - config: - baseURL: https://dev.example.com - debugMode: true - timeout: 30 - secrets: - api_key: dev-key-12345 - db_password: $ENV:DEV_DB_PASSWORD - - prod: - config: - baseURL: https://prod.example.com - debugMode: false - timeout: 10 - GCPProject: my-project-123 - AWSRegion: us-west-2 - secrets: - api_key: $GSM:prod-api-key - db_password: $ASM:prod/db/password - -configDefaults: - app_name: my-app - timeout: 30 - log_level: INFO - port: 8080 -``` - -## How It Works - -1. **Environment Selection**: Call `config.SetEnvironment("prod")` to select which environment to use - -2. **Value Lookup**: When you call `config.Get("key")`: - - First checks `environments..config.key` - - Falls back to `configDefaults.key` - - Returns the default value if not found - -3. **Secret Lookup**: When you call `config.GetSecret("key")`: - - Looks in `environments..secrets.key` - - Returns the default value if not found - -4. **Value Resolution**: If a value starts with a special prefix: - - `$ENV:` - Reads from environment variable - - `$GSM:` - Fetches from Google Secret Manager (requires GCPProject to be set in config) - - `$ASM:` - Fetches from AWS Secrets Manager (uses AWSRegion from config or defaults to us-east-1) - - `$FILE:` - Reads from file (supports `~` expansion) - -## Type-Safe Access - -The module provides type-safe helper functions: - -```go -// String values -baseURL := config.GetString("baseURL", "http://localhost") - -// Integer values -port := config.GetInt("port", 8080) - -// Boolean values -debug := config.GetBool("debug", false) - -// Secret string values -apiKey := config.GetSecretString("api_key", "default-key") -``` - -## Local Development - -For local development, you can: - -1. Use environment variables: - ```yaml - secrets: - api_key: $ENV:LOCAL_API_KEY - ``` - -2. Use local files: - ```yaml - secrets: - api_key: $FILE:~/.secrets/api-key.txt - ``` - -3. Create a `config.local.yaml` (gitignored) with literal values for testing - -## Cloud Provider Support - -### Google Secret Manager - -To use GSM resolution (`$GSM:` prefix): -1. Set `GCPProject` in your config -2. Ensure proper authentication (e.g., `GOOGLE_APPLICATION_CREDENTIALS` environment variable) -3. The module will automatically initialize the GSM client when needed - -### AWS Secrets Manager - -To use ASM resolution (`$ASM:` prefix): -1. Optionally set `AWSRegion` in your config (defaults to us-east-1) -2. Ensure proper authentication (e.g., AWS credentials in environment or IAM role) -3. The module will automatically initialize the ASM client when needed - -## Advanced Usage - -### Loading from a Specific File - -```go -// Load configuration from a specific file -if err := config.LoadFile("/path/to/config.yaml"); err != nil { - log.Fatal(err) -} -``` - -### Checking Configuration Values - -```go -// Get all configuration for current environment -allConfig := config.GetAllConfig() -for key, value := range allConfig { - fmt.Printf("%s: %v\n", key, value) -} - -// Get all secrets (be careful with logging!) -allSecrets := config.GetAllSecrets() -``` - -## Testing - -The module uses the [afero](https://github.com/spf13/afero) filesystem abstraction, making it easy to test without real files: - -```go -package myapp_test - -import ( - "testing" - "github.com/spf13/afero" - "git.eeqj.de/sneak/webhooker/pkg/config" -) - -func TestMyApp(t *testing.T) { - // Create an in-memory filesystem for testing - fs := afero.NewMemMapFs() - - // Write a test config file - testConfig := ` -environments: - test: - config: - apiURL: http://test.example.com - secrets: - apiKey: test-key-123 -` - afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644) - - // Use the test filesystem - config.SetFs(fs) - config.SetEnvironment("test") - - // Now your tests use the in-memory config - if url := config.GetString("apiURL"); url != "http://test.example.com" { - t.Errorf("Expected test URL, got %s", url) - } -} -``` - -### Unit Testing with Isolated Config - -For unit tests, you can create isolated configuration managers: - -```go -func TestMyComponent(t *testing.T) { - // Create a test-specific manager - manager := config.NewManager() - - // Use in-memory filesystem - fs := afero.NewMemMapFs() - afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644) - manager.SetFs(fs) - - // Test with isolated configuration - manager.SetEnvironment("test") - value := manager.Get("someKey", "default") -} -``` - -## Error Handling - -- If a config file is not found when using the default loader, an error is returned -- If a key is not found, the default value is returned -- If a special value cannot be resolved (e.g., env var not set, file not found), `nil` is returned -- Cloud provider errors are logged but return `nil` to allow graceful degradation - -## Thread Safety - -All operations are thread-safe. The module uses read-write mutexes to ensure safe concurrent access to configuration data. - -## Example Integration - -```go -package main - -import ( - "log" - "os" - "git.eeqj.de/sneak/webhooker/pkg/config" -) - -func main() { - // Read environment from your app-specific env var - environment := os.Getenv("APP_ENV") - if environment == "" { - environment = "dev" - } - - config.SetEnvironment(environment) - - // Now use configuration throughout your app - databaseURL := config.GetString("database_url") - apiKey := config.GetSecretString("api_key") - - log.Printf("Running in %s environment", environment) - log.Printf("Database URL: %s", databaseURL) -} -``` - -## Migration from Python Version - -The Go version maintains API compatibility with the Python version where possible: - -| Python | Go | -|--------|-----| -| `config.get('key')` | `config.Get("key")` or `config.GetString("key")` | -| `config.getSecret('key')` | `config.GetSecret("key")` or `config.GetSecretString("key")` | -| `config.set_environment('prod')` | `config.SetEnvironment("prod")` | -| `config.reload()` | `config.Reload()` | -| `config.get_all_config()` | `config.GetAllConfig()` | -| `config.get_all_secrets()` | `config.GetAllSecrets()` | - -## License - -This module is designed to be standalone and can be extracted into its own repository with your preferred license. \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go deleted file mode 100644 index 820c779..0000000 --- a/pkg/config/config.go +++ /dev/null @@ -1,180 +0,0 @@ -// Package config provides a simple, clean, and generic configuration management system -// that supports multiple environments and automatic value resolution. -// -// Features: -// - Simple API: Just config.Get() and config.GetSecret() -// - Environment Support: Separate configs for different environments (dev/prod/staging/etc) -// - Value Resolution: Automatic resolution of special values: -// - $ENV:VARIABLE - Read from environment variable -// - $GSM:secret-name - Read from Google Secret Manager -// - $ASM:secret-name - Read from AWS Secrets Manager -// - $FILE:/path/to/file - Read from file contents -// - Hierarchical Defaults: Environment-specific values override defaults -// - YAML-based: Easy to read and edit configuration files -// - Zero Dependencies: Only depends on yaml and cloud provider SDKs (optional) -// -// Usage: -// -// import "sneak.berlin/go/webhooker/pkg/config" -// -// // Set the environment explicitly -// config.SetEnvironment("prod") -// -// // Get configuration values -// baseURL := config.Get("baseURL") -// apiTimeout := config.GetInt("timeout", 30) -// -// // Get secret values -// apiKey := config.GetSecret("api_key") -// dbPassword := config.GetSecret("db_password", "default") -package config - -import ( - "sync" - - "github.com/spf13/afero" -) - -// Global configuration manager instance -var ( - globalManager *Manager - mu sync.Mutex // Protect global manager updates -) - -// getManager returns the global configuration manager, creating it if necessary -func getManager() *Manager { - mu.Lock() - defer mu.Unlock() - - if globalManager == nil { - globalManager = NewManager() - } - return globalManager -} - -// SetEnvironment sets the active environment. -func SetEnvironment(environment string) { - getManager().SetEnvironment(environment) -} - -// SetFs sets the filesystem to use for all file operations. -// This is primarily useful for testing with an in-memory filesystem. -func SetFs(fs afero.Fs) { - mu.Lock() - defer mu.Unlock() - - // Create a new manager with the specified filesystem - newManager := NewManager() - newManager.SetFs(fs) - - // Replace the global manager - globalManager = newManager -} - -// Get retrieves a configuration value. -// -// This looks for values in the following order: -// 1. Environment-specific config (environments..config.) -// 2. Config defaults (configDefaults.) -// -// Values are resolved if they contain special prefixes: -// - $ENV:VARIABLE_NAME - reads from environment variable -// - $GSM:secret-name - reads from Google Secret Manager -// - $ASM:secret-name - reads from AWS Secrets Manager -// - $FILE:/path/to/file - reads from file -func Get(key string, defaultValue ...interface{}) interface{} { - var def interface{} - if len(defaultValue) > 0 { - def = defaultValue[0] - } - return getManager().Get(key, def) -} - -// GetString retrieves a configuration value as a string. -func GetString(key string, defaultValue ...string) string { - var def string - if len(defaultValue) > 0 { - def = defaultValue[0] - } - val := Get(key, def) - if s, ok := val.(string); ok { - return s - } - return def -} - -// GetInt retrieves a configuration value as an integer. -func GetInt(key string, defaultValue ...int) int { - var def int - if len(defaultValue) > 0 { - def = defaultValue[0] - } - val := Get(key, def) - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - default: - return def - } -} - -// GetBool retrieves a configuration value as a boolean. -func GetBool(key string, defaultValue ...bool) bool { - var def bool - if len(defaultValue) > 0 { - def = defaultValue[0] - } - val := Get(key, def) - if b, ok := val.(bool); ok { - return b - } - return def -} - -// GetSecret retrieves a secret value. -// -// This looks for secrets defined in environments..secrets. -func GetSecret(key string, defaultValue ...interface{}) interface{} { - var def interface{} - if len(defaultValue) > 0 { - def = defaultValue[0] - } - return getManager().GetSecret(key, def) -} - -// GetSecretString retrieves a secret value as a string. -func GetSecretString(key string, defaultValue ...string) string { - var def string - if len(defaultValue) > 0 { - def = defaultValue[0] - } - val := GetSecret(key, def) - if s, ok := val.(string); ok { - return s - } - return def -} - -// Reload reloads the configuration from file. -func Reload() error { - return getManager().Reload() -} - -// GetAllConfig returns all configuration values for the current environment. -func GetAllConfig() map[string]interface{} { - return getManager().GetAllConfig() -} - -// GetAllSecrets returns all secrets for the current environment. -func GetAllSecrets() map[string]interface{} { - return getManager().GetAllSecrets() -} - -// LoadFile loads configuration from a specific file. -func LoadFile(configFile string) error { - return getManager().LoadFile(configFile) -} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go deleted file mode 100644 index 89cf2e7..0000000 --- a/pkg/config/config_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package config - -import ( - "os" - "testing" - - "github.com/spf13/afero" -) - -func TestNewManager(t *testing.T) { - manager := NewManager() - if manager == nil { - t.Fatal("NewManager returned nil") - } - if manager.config == nil { - t.Error("Manager config map is nil") - } - if manager.loader == nil { - t.Error("Manager loader is nil") - } - if manager.resolvedCache == nil { - t.Error("Manager resolvedCache is nil") - } - if manager.fs == nil { - t.Error("Manager fs is nil") - } -} - -func TestLoader_FindConfigFile(t *testing.T) { - // Create an in-memory filesystem for testing - fs := afero.NewMemMapFs() - loader := NewLoader(fs) - - // Create a config file in the filesystem - configContent := ` -environments: - test: - config: - testKey: testValue - secrets: - testSecret: secretValue -configDefaults: - defaultKey: defaultValue -` - // Create the file in the current directory - if err := afero.WriteFile(fs, "config.yaml", []byte(configContent), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - // Test finding the config file - foundPath, err := loader.FindConfigFile("config.yaml") - if err != nil { - t.Errorf("FindConfigFile failed: %v", err) - } - - // In memory fs, the path should be exactly what we created - if foundPath != "config.yaml" { - t.Errorf("Expected config.yaml, got %s", foundPath) - } -} - -func TestLoader_LoadYAML(t *testing.T) { - fs := afero.NewMemMapFs() - loader := NewLoader(fs) - - // Create a test config file - testConfig := ` -environments: - test: - config: - testKey: testValue -configDefaults: - defaultKey: defaultValue -` - if err := afero.WriteFile(fs, "test-config.yaml", []byte(testConfig), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - // Load the YAML - config, err := loader.LoadYAML("test-config.yaml") - if err != nil { - t.Fatalf("LoadYAML failed: %v", err) - } - - // Verify the structure - envs, ok := config["environments"].(map[string]interface{}) - if !ok { - t.Fatal("environments not found or wrong type") - } - - testEnv, ok := envs["test"].(map[string]interface{}) - if !ok { - t.Fatal("test environment not found") - } - - testConfig2, ok := testEnv["config"].(map[string]interface{}) - if !ok { - t.Fatal("test config not found") - } - - if testConfig2["testKey"] != "testValue" { - t.Errorf("Expected testKey=testValue, got %v", testConfig2["testKey"]) - } -} - -func TestResolver_ResolveEnv(t *testing.T) { - fs := afero.NewMemMapFs() - resolver := NewResolver("", "", fs) - - // Set a test environment variable - os.Setenv("TEST_CONFIG_VAR", "test-value") - defer os.Unsetenv("TEST_CONFIG_VAR") - - // Test resolving environment variable - result := resolver.Resolve("$ENV:TEST_CONFIG_VAR") - if result != "test-value" { - t.Errorf("Expected 'test-value', got %v", result) - } - - // Test non-existent env var - result = resolver.Resolve("$ENV:NON_EXISTENT_VAR") - if result != nil { - t.Errorf("Expected nil for non-existent env var, got %v", result) - } -} - -func TestResolver_ResolveFile(t *testing.T) { - fs := afero.NewMemMapFs() - resolver := NewResolver("", "", fs) - - // Create a test file - secretContent := "my-secret-value" - if err := afero.WriteFile(fs, "/test-secret.txt", []byte(secretContent+"\n"), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - // Test resolving file - result := resolver.Resolve("$FILE:/test-secret.txt") - if result != secretContent { - t.Errorf("Expected '%s', got %v", secretContent, result) - } - - // Test non-existent file - result = resolver.Resolve("$FILE:/non/existent/file") - if result != nil { - t.Errorf("Expected nil for non-existent file, got %v", result) - } -} - -func TestManager_GetAndSet(t *testing.T) { - // Create an in-memory filesystem - fs := afero.NewMemMapFs() - - // Create a test config file - testConfig := ` -environments: - dev: - config: - apiURL: http://dev.example.com - timeout: 30 - debug: true - secrets: - apiKey: dev-key-123 - prod: - config: - apiURL: https://prod.example.com - timeout: 10 - debug: false - secrets: - apiKey: $ENV:PROD_API_KEY -configDefaults: - appName: TestApp - timeout: 20 - port: 8080 -` - if err := afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - // Create manager and set the filesystem - manager := NewManager() - manager.SetFs(fs) - - // Load config should find the file automatically - manager.SetEnvironment("dev") - - // Test getting config values - if v := manager.Get("apiURL", ""); v != "http://dev.example.com" { - t.Errorf("Expected dev apiURL, got %v", v) - } - - if v := manager.Get("timeout", 0); v != 30 { - t.Errorf("Expected timeout=30, got %v", v) - } - - if v := manager.Get("debug", false); v != true { - t.Errorf("Expected debug=true, got %v", v) - } - - // Test default values - if v := manager.Get("appName", ""); v != "TestApp" { - t.Errorf("Expected appName from defaults, got %v", v) - } - - // Test getting secrets - if v := manager.GetSecret("apiKey", ""); v != "dev-key-123" { - t.Errorf("Expected dev apiKey, got %v", v) - } - - // Switch to prod environment - manager.SetEnvironment("prod") - - if v := manager.Get("apiURL", ""); v != "https://prod.example.com" { - t.Errorf("Expected prod apiURL, got %v", v) - } - - // Test environment variable resolution in secrets - os.Setenv("PROD_API_KEY", "prod-key-456") - defer os.Unsetenv("PROD_API_KEY") - - if v := manager.GetSecret("apiKey", ""); v != "prod-key-456" { - t.Errorf("Expected resolved env var for apiKey, got %v", v) - } -} - -func TestGlobalAPI(t *testing.T) { - // Create an in-memory filesystem - fs := afero.NewMemMapFs() - - // Create a test config file - testConfig := ` -environments: - test: - config: - stringVal: hello - intVal: 42 - boolVal: true - secrets: - secret1: test-secret -configDefaults: - defaultString: world -` - if err := afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - // Use the global API with the test filesystem - SetFs(fs) - SetEnvironment("test") - - // Test type-safe getters - if v := GetString("stringVal"); v != "hello" { - t.Errorf("Expected 'hello', got %v", v) - } - - if v := GetInt("intVal"); v != 42 { - t.Errorf("Expected 42, got %v", v) - } - - if v := GetBool("boolVal"); v != true { - t.Errorf("Expected true, got %v", v) - } - - if v := GetSecretString("secret1"); v != "test-secret" { - t.Errorf("Expected 'test-secret', got %v", v) - } - - // Test defaults - if v := GetString("defaultString"); v != "world" { - t.Errorf("Expected 'world', got %v", v) - } -} - -func TestManager_SetFs(t *testing.T) { - // Create manager with default OS filesystem - manager := NewManager() - - // Create an in-memory filesystem - memFs := afero.NewMemMapFs() - - // Write a config file to the memory fs - testConfig := ` -environments: - test: - config: - testKey: fromMemory -configDefaults: - defaultKey: memoryDefault -` - if err := afero.WriteFile(memFs, "config.yaml", []byte(testConfig), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - // Set the filesystem - manager.SetFs(memFs) - manager.SetEnvironment("test") - - // Test that it reads from the memory filesystem - if v := manager.Get("testKey", ""); v != "fromMemory" { - t.Errorf("Expected 'fromMemory', got %v", v) - } - - if v := manager.Get("defaultKey", ""); v != "memoryDefault" { - t.Errorf("Expected 'memoryDefault', got %v", v) - } -} diff --git a/pkg/config/example_afero_test.go b/pkg/config/example_afero_test.go deleted file mode 100644 index 7fc0e64..0000000 --- a/pkg/config/example_afero_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package config_test - -import ( - "fmt" - "testing" - - "github.com/spf13/afero" - "sneak.berlin/go/webhooker/pkg/config" -) - -// ExampleSetFs demonstrates how to use an in-memory filesystem for testing -func ExampleSetFs() { - // Create an in-memory filesystem - fs := afero.NewMemMapFs() - - // Create a test configuration file - configYAML := ` -environments: - test: - config: - baseURL: https://test.example.com - debugMode: true - secrets: - apiKey: test-key-12345 - production: - config: - baseURL: https://api.example.com - debugMode: false -configDefaults: - appName: Test Application - timeout: 30 -` - - // Write the config to the in-memory filesystem - if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil { - panic(err) - } - - // Use the in-memory filesystem - config.SetFs(fs) - config.SetEnvironment("test") - - // Now all config operations use the in-memory filesystem - fmt.Printf("Base URL: %s\n", config.GetString("baseURL")) - fmt.Printf("Debug Mode: %v\n", config.GetBool("debugMode")) - fmt.Printf("App Name: %s\n", config.GetString("appName")) - - // Output: - // Base URL: https://test.example.com - // Debug Mode: true - // App Name: Test Application -} - -// TestWithAferoFilesystem shows how to test with different filesystem implementations -func TestWithAferoFilesystem(t *testing.T) { - tests := []struct { - name string - setupFs func() afero.Fs - environment string - key string - expected string - }{ - { - name: "in-memory filesystem", - setupFs: func() afero.Fs { - fs := afero.NewMemMapFs() - config := ` -environments: - dev: - config: - apiURL: http://localhost:8080 -` - afero.WriteFile(fs, "config.yaml", []byte(config), 0644) - return fs - }, - environment: "dev", - key: "apiURL", - expected: "http://localhost:8080", - }, - { - name: "readonly filesystem", - setupFs: func() afero.Fs { - memFs := afero.NewMemMapFs() - config := ` -environments: - staging: - config: - apiURL: https://staging.example.com -` - afero.WriteFile(memFs, "config.yaml", []byte(config), 0644) - // Wrap in a read-only filesystem - return afero.NewReadOnlyFs(memFs) - }, - environment: "staging", - key: "apiURL", - expected: "https://staging.example.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a new manager for each test to ensure isolation - manager := config.NewManager() - manager.SetFs(tt.setupFs()) - manager.SetEnvironment(tt.environment) - - result := manager.Get(tt.key, "") - if result != tt.expected { - t.Errorf("Expected %s, got %v", tt.expected, result) - } - }) - } -} - -// TestFileResolution shows how $FILE: resolution works with afero -func TestFileResolution(t *testing.T) { - // Create an in-memory filesystem - fs := afero.NewMemMapFs() - - // Create a secret file - secretContent := "super-secret-api-key" - if err := afero.WriteFile(fs, "/secrets/api-key.txt", []byte(secretContent), 0600); err != nil { - t.Fatal(err) - } - - // Create a config that references the file - configYAML := ` -environments: - prod: - secrets: - apiKey: $FILE:/secrets/api-key.txt -` - if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil { - t.Fatal(err) - } - - // Use the filesystem - config.SetFs(fs) - config.SetEnvironment("prod") - - // Get the secret - it should resolve from the file - apiKey := config.GetSecretString("apiKey") - if apiKey != secretContent { - t.Errorf("Expected %s, got %s", secretContent, apiKey) - } -} diff --git a/pkg/config/example_test.go b/pkg/config/example_test.go deleted file mode 100644 index 007d7b6..0000000 --- a/pkg/config/example_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package config_test - -import ( - "fmt" - "log" - "os" - - "sneak.berlin/go/webhooker/pkg/config" -) - -func Example() { - // Set the environment explicitly - config.SetEnvironment("dev") - - // Get configuration values - baseURL := config.GetString("baseURL") - timeout := config.GetInt("timeout", 30) - debugMode := config.GetBool("debugMode", false) - - fmt.Printf("Base URL: %s\n", baseURL) - fmt.Printf("Timeout: %d\n", timeout) - fmt.Printf("Debug Mode: %v\n", debugMode) - - // Get secret values - apiKey := config.GetSecretString("api_key") - if apiKey != "" { - fmt.Printf("API Key: %s...\n", apiKey[:8]) - } -} - -func ExampleSetEnvironment() { - // Your application determines which environment to use - // This could come from command line args, env vars, etc. - environment := os.Getenv("APP_ENV") - if environment == "" { - environment = "development" - } - - // Set the environment explicitly - config.SetEnvironment(environment) - - // Now use configuration throughout your application - fmt.Printf("Environment: %s\n", environment) - fmt.Printf("App Name: %s\n", config.GetString("app_name")) -} - -func ExampleGetString() { - config.SetEnvironment("prod") - - // Get a string configuration value with a default - baseURL := config.GetString("baseURL", "http://localhost:8080") - fmt.Printf("Base URL: %s\n", baseURL) -} - -func ExampleGetInt() { - config.SetEnvironment("prod") - - // Get an integer configuration value with a default - port := config.GetInt("port", 8080) - fmt.Printf("Port: %d\n", port) -} - -func ExampleGetBool() { - config.SetEnvironment("dev") - - // Get a boolean configuration value with a default - debugMode := config.GetBool("debugMode", false) - fmt.Printf("Debug Mode: %v\n", debugMode) -} - -func ExampleGetSecretString() { - config.SetEnvironment("prod") - - // Get a secret string value - apiKey := config.GetSecretString("api_key") - if apiKey != "" { - // Be careful not to log the full secret! - fmt.Printf("API Key configured: yes\n") - } -} - -func ExampleLoadFile() { - // Load configuration from a specific file - if err := config.LoadFile("/path/to/config.yaml"); err != nil { - log.Printf("Failed to load config: %v", err) - return - } - - config.SetEnvironment("staging") - fmt.Printf("Loaded configuration from custom file\n") -} - -func ExampleReload() { - config.SetEnvironment("dev") - - // Get initial value - oldValue := config.GetString("some_key") - - // ... config file might have been updated ... - - // Reload configuration from file - if err := config.Reload(); err != nil { - log.Printf("Failed to reload config: %v", err) - return - } - - // Get potentially updated value - newValue := config.GetString("some_key") - fmt.Printf("Value changed: %v\n", oldValue != newValue) -} - -// Example config.yaml structure: -/* -environments: - development: - config: - baseURL: http://localhost:8000 - debugMode: true - port: 8000 - secrets: - api_key: dev-key-12345 - - production: - config: - baseURL: https://api.example.com - debugMode: false - port: 443 - GCPProject: my-project-123 - AWSRegion: us-west-2 - secrets: - api_key: $GSM:prod-api-key - db_password: $ASM:prod/db/password - -configDefaults: - app_name: My Application - timeout: 30 - log_level: INFO - port: 8080 -*/ diff --git a/pkg/config/go.mod b/pkg/config/go.mod deleted file mode 100644 index 57aa52f..0000000 --- a/pkg/config/go.mod +++ /dev/null @@ -1,41 +0,0 @@ -module sneak.berlin/go/webhooker/pkg/config - -go 1.23.0 - -toolchain go1.24.1 - -require ( - github.com/aws/aws-sdk-go v1.50.0 - github.com/spf13/afero v1.14.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - cloud.google.com/go/compute v1.23.1 // indirect - cloud.google.com/go/compute/metadata v0.2.3 // indirect - cloud.google.com/go/iam v1.1.3 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/s2a-go v0.1.7 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.0 // indirect - go.opencensus.io v0.24.0 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/oauth2 v0.13.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.23.0 // indirect - google.golang.org/api v0.149.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect - google.golang.org/grpc v1.59.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect -) - -require ( - cloud.google.com/go/secretmanager v1.11.4 - github.com/jmespath/go-jmespath v0.4.0 // indirect -) diff --git a/pkg/config/go.sum b/pkg/config/go.sum deleted file mode 100644 index 6b66379..0000000 --- a/pkg/config/go.sum +++ /dev/null @@ -1,161 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME= -cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= -cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0= -cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc= -cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE= -cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k= -cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI= -github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= -github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= -golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.149.0 h1:b2CqT6kG+zqJIVKRQ3ELJVLN1PwHZ6DJ3dW8yl82rgY= -google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= -google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k= -google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/config/loader.go b/pkg/config/loader.go deleted file mode 100644 index 2a87d97..0000000 --- a/pkg/config/loader.go +++ /dev/null @@ -1,104 +0,0 @@ -package config - -import ( - "fmt" - "path/filepath" - - "github.com/spf13/afero" - "gopkg.in/yaml.v3" -) - -// Loader handles loading configuration from YAML files. -type Loader struct { - fs afero.Fs -} - -// NewLoader creates a new configuration loader. -func NewLoader(fs afero.Fs) *Loader { - return &Loader{ - fs: fs, - } -} - -// FindConfigFile searches for a configuration file by looking up the directory tree. -func (l *Loader) FindConfigFile(filename string) (string, error) { - if filename == "" { - filename = "config.yaml" - } - - // First check if the file exists in the current directory (simple case) - if _, err := l.fs.Stat(filename); err == nil { - return filename, nil - } - - // For more complex cases, try to walk up the directory tree - // Start from current directory or root for in-memory filesystems - currentDir := "." - - // Try to get the absolute path, but if it fails (e.g., in-memory fs), - // just use the current directory - if absPath, err := filepath.Abs("."); err == nil { - currentDir = absPath - } - - // Search up the directory tree - for { - configPath := filepath.Join(currentDir, filename) - if _, err := l.fs.Stat(configPath); err == nil { - return configPath, nil - } - - // Move up one directory - parentDir := filepath.Dir(currentDir) - if parentDir == currentDir || currentDir == "." || currentDir == "/" { - // Reached the root directory or can't go up further - break - } - currentDir = parentDir - } - - return "", fmt.Errorf("configuration file %s not found in directory tree", filename) -} - -// LoadYAML loads a YAML file and returns the parsed configuration. -func (l *Loader) LoadYAML(filePath string) (map[string]interface{}, error) { - data, err := afero.ReadFile(l.fs, filePath) - if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) - } - - var config map[string]interface{} - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse YAML from %s: %w", filePath, err) - } - - if config == nil { - config = make(map[string]interface{}) - } - - return config, nil -} - -// MergeConfigs performs a deep merge of two configuration maps. -// The override map values take precedence over the base map. -func (l *Loader) MergeConfigs(base, override map[string]interface{}) map[string]interface{} { - if base == nil { - base = make(map[string]interface{}) - } - - for key, value := range override { - if baseValue, exists := base[key]; exists { - // If both values are maps, merge them recursively - if baseMap, baseOk := baseValue.(map[string]interface{}); baseOk { - if overrideMap, overrideOk := value.(map[string]interface{}); overrideOk { - base[key] = l.MergeConfigs(baseMap, overrideMap) - continue - } - } - } - // Otherwise, override the value - base[key] = value - } - - return base -} diff --git a/pkg/config/manager.go b/pkg/config/manager.go deleted file mode 100644 index e19d5c6..0000000 --- a/pkg/config/manager.go +++ /dev/null @@ -1,373 +0,0 @@ -package config - -import ( - "fmt" - "log" - "strings" - "sync" - - "github.com/spf13/afero" -) - -// Manager manages application configuration with value resolution. -type Manager struct { - mu sync.RWMutex - config map[string]interface{} - environment string - resolver *Resolver - loader *Loader - configFile string - resolvedCache map[string]interface{} - fs afero.Fs -} - -// NewManager creates a new configuration manager. -func NewManager() *Manager { - fs := afero.NewOsFs() - return &Manager{ - config: make(map[string]interface{}), - loader: NewLoader(fs), - resolvedCache: make(map[string]interface{}), - fs: fs, - } -} - -// SetFs sets the filesystem to use for all file operations. -// This is primarily useful for testing with an in-memory filesystem. -func (m *Manager) SetFs(fs afero.Fs) { - m.mu.Lock() - defer m.mu.Unlock() - - m.fs = fs - m.loader = NewLoader(fs) - - // If we have a resolver, recreate it with the new fs - if m.resolver != nil { - gcpProject := "" - awsRegion := "us-east-1" - - // Try to get the current settings - if gcpProj := m.getConfigValue("GCPProject", ""); gcpProj != nil { - if str, ok := gcpProj.(string); ok { - gcpProject = str - } - } - if awsReg := m.getConfigValue("AWSRegion", "us-east-1"); awsReg != nil { - if str, ok := awsReg.(string); ok { - awsRegion = str - } - } - - m.resolver = NewResolver(gcpProject, awsRegion, fs) - } - - // Clear caches as filesystem changed - m.resolvedCache = make(map[string]interface{}) -} - -// LoadFile loads configuration from a specific file. -func (m *Manager) LoadFile(configFile string) error { - m.mu.Lock() - defer m.mu.Unlock() - - config, err := m.loader.LoadYAML(configFile) - if err != nil { - return err - } - - m.config = config - m.configFile = configFile - m.resolvedCache = make(map[string]interface{}) // Clear cache - return nil -} - -// loadConfig loads the configuration from file. -func (m *Manager) loadConfig() error { - if m.configFile == "" { - // Try to find config.yaml - configPath, err := m.loader.FindConfigFile("config.yaml") - if err != nil { - return err - } - m.configFile = configPath - } - - config, err := m.loader.LoadYAML(m.configFile) - if err != nil { - return err - } - - m.config = config - m.resolvedCache = make(map[string]interface{}) // Clear cache - return nil -} - -// SetEnvironment sets the active environment. -func (m *Manager) SetEnvironment(environment string) { - m.mu.Lock() - defer m.mu.Unlock() - - m.environment = strings.ToLower(environment) - - // Create resolver with GCP project and AWS region if available - gcpProject := m.getConfigValue("GCPProject", "") - awsRegion := m.getConfigValue("AWSRegion", "us-east-1") - - if gcpProjectStr, ok := gcpProject.(string); ok { - if awsRegionStr, ok := awsRegion.(string); ok { - m.resolver = NewResolver(gcpProjectStr, awsRegionStr, m.fs) - } - } - - // Clear resolved cache when environment changes - m.resolvedCache = make(map[string]interface{}) -} - -// Get retrieves a configuration value. -func (m *Manager) Get(key string, defaultValue interface{}) interface{} { - m.mu.RLock() - - // Ensure config is loaded - if m.config == nil || len(m.config) == 0 { - // Need to upgrade to write lock to load config - m.mu.RUnlock() - m.mu.Lock() - // Double-check after acquiring write lock - if m.config == nil || len(m.config) == 0 { - if err := m.loadConfig(); err != nil { - log.Printf("Failed to load config: %v", err) - m.mu.Unlock() - return defaultValue - } - } - // Downgrade back to read lock - m.mu.Unlock() - m.mu.RLock() - } - defer m.mu.RUnlock() - - // Check cache first - cacheKey := fmt.Sprintf("config.%s", key) - if cached, ok := m.resolvedCache[cacheKey]; ok { - return cached - } - - // Try environment-specific config first - var rawValue interface{} - if m.environment != "" { - envMap, ok := m.config["environments"].(map[string]interface{}) - if ok { - if env, ok := envMap[m.environment].(map[string]interface{}); ok { - if config, ok := env["config"].(map[string]interface{}); ok { - if val, exists := config[key]; exists { - rawValue = val - } - } - } - } - } - - // Fall back to configDefaults - if rawValue == nil { - if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok { - if val, exists := defaults[key]; exists { - rawValue = val - } - } - } - - if rawValue == nil { - return defaultValue - } - - // Resolve the value if we have a resolver - var resolvedValue interface{} - if m.resolver != nil { - resolvedValue = m.resolver.Resolve(rawValue) - } else { - resolvedValue = rawValue - } - - // Cache the resolved value - m.resolvedCache[cacheKey] = resolvedValue - - return resolvedValue -} - -// GetSecret retrieves a secret value for the current environment. -func (m *Manager) GetSecret(key string, defaultValue interface{}) interface{} { - m.mu.RLock() - - // Ensure config is loaded - if m.config == nil || len(m.config) == 0 { - // Need to upgrade to write lock to load config - m.mu.RUnlock() - m.mu.Lock() - // Double-check after acquiring write lock - if m.config == nil || len(m.config) == 0 { - if err := m.loadConfig(); err != nil { - log.Printf("Failed to load config: %v", err) - m.mu.Unlock() - return defaultValue - } - } - // Downgrade back to read lock - m.mu.Unlock() - m.mu.RLock() - } - defer m.mu.RUnlock() - - if m.environment == "" { - log.Printf("No environment set when getting secret '%s'", key) - return defaultValue - } - - // Get the current environment's config - envMap, ok := m.config["environments"].(map[string]interface{}) - if !ok { - return defaultValue - } - - env, ok := envMap[m.environment].(map[string]interface{}) - if !ok { - return defaultValue - } - - secrets, ok := env["secrets"].(map[string]interface{}) - if !ok { - return defaultValue - } - - secretValue, exists := secrets[key] - if !exists { - return defaultValue - } - - // Resolve the value - if m.resolver != nil { - resolved := m.resolver.Resolve(secretValue) - if resolved == nil { - return defaultValue - } - return resolved - } - - return secretValue -} - -// getConfigValue is an internal helper to get config values without locking. -func (m *Manager) getConfigValue(key string, defaultValue interface{}) interface{} { - // Try environment-specific config first - var rawValue interface{} - if m.environment != "" { - envMap, ok := m.config["environments"].(map[string]interface{}) - if ok { - if env, ok := envMap[m.environment].(map[string]interface{}); ok { - if config, ok := env["config"].(map[string]interface{}); ok { - if val, exists := config[key]; exists { - rawValue = val - } - } - } - } - } - - // Fall back to configDefaults - if rawValue == nil { - if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok { - if val, exists := defaults[key]; exists { - rawValue = val - } - } - } - - if rawValue == nil { - return defaultValue - } - - return rawValue -} - -// Reload reloads the configuration from file. -func (m *Manager) Reload() error { - m.mu.Lock() - defer m.mu.Unlock() - - return m.loadConfig() -} - -// GetAllConfig returns all configuration values for the current environment. -func (m *Manager) GetAllConfig() map[string]interface{} { - m.mu.RLock() - defer m.mu.RUnlock() - - result := make(map[string]interface{}) - - // Start with configDefaults - if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok { - for k, v := range defaults { - if m.resolver != nil { - result[k] = m.resolver.Resolve(v) - } else { - result[k] = v - } - } - } - - // Override with environment-specific config - if m.environment != "" { - envMap, ok := m.config["environments"].(map[string]interface{}) - if ok { - if env, ok := envMap[m.environment].(map[string]interface{}); ok { - if config, ok := env["config"].(map[string]interface{}); ok { - for k, v := range config { - if m.resolver != nil { - result[k] = m.resolver.Resolve(v) - } else { - result[k] = v - } - } - } - } - } - } - - return result -} - -// GetAllSecrets returns all secrets for the current environment. -func (m *Manager) GetAllSecrets() map[string]interface{} { - m.mu.RLock() - defer m.mu.RUnlock() - - if m.environment == "" { - return make(map[string]interface{}) - } - - envMap, ok := m.config["environments"].(map[string]interface{}) - if !ok { - return make(map[string]interface{}) - } - - env, ok := envMap[m.environment].(map[string]interface{}) - if !ok { - return make(map[string]interface{}) - } - - secrets, ok := env["secrets"].(map[string]interface{}) - if !ok { - return make(map[string]interface{}) - } - - // Resolve all secrets - result := make(map[string]interface{}) - for k, v := range secrets { - if m.resolver != nil { - result[k] = m.resolver.Resolve(v) - } else { - result[k] = v - } - } - - return result -} diff --git a/pkg/config/resolver.go b/pkg/config/resolver.go deleted file mode 100644 index d6e6a21..0000000 --- a/pkg/config/resolver.go +++ /dev/null @@ -1,204 +0,0 @@ -package config - -import ( - "context" - "fmt" - "log" - "os" - "path/filepath" - "regexp" - "strings" - - secretmanager "cloud.google.com/go/secretmanager/apiv1" - "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/secretsmanager" - "github.com/spf13/afero" -) - -// Resolver handles resolution of configuration values with special prefixes. -type Resolver struct { - gcpProject string - awsRegion string - gsmClient *secretmanager.Client - asmClient *secretsmanager.SecretsManager - awsSession *session.Session - specialValue *regexp.Regexp - fs afero.Fs -} - -// NewResolver creates a new value resolver. -func NewResolver(gcpProject, awsRegion string, fs afero.Fs) *Resolver { - return &Resolver{ - gcpProject: gcpProject, - awsRegion: awsRegion, - specialValue: regexp.MustCompile(`^\$([A-Z]+):(.+)$`), - fs: fs, - } -} - -// Resolve resolves a configuration value that may contain special prefixes. -func (r *Resolver) Resolve(value interface{}) interface{} { - switch v := value.(type) { - case string: - return r.resolveString(v) - case map[string]interface{}: - // Recursively resolve map values - result := make(map[string]interface{}) - for k, val := range v { - result[k] = r.Resolve(val) - } - return result - case []interface{}: - // Recursively resolve slice items - result := make([]interface{}, len(v)) - for i, val := range v { - result[i] = r.Resolve(val) - } - return result - default: - // Return non-string values as-is - return value - } -} - -// resolveString resolves a string value that may contain a special prefix. -func (r *Resolver) resolveString(value string) interface{} { - matches := r.specialValue.FindStringSubmatch(value) - if matches == nil { - return value - } - - resolverType := matches[1] - resolverValue := matches[2] - - switch resolverType { - case "ENV": - return r.resolveEnv(resolverValue) - case "GSM": - return r.resolveGSM(resolverValue) - case "ASM": - return r.resolveASM(resolverValue) - case "FILE": - return r.resolveFile(resolverValue) - default: - log.Printf("Unknown resolver type: %s", resolverType) - return value - } -} - -// resolveEnv resolves an environment variable. -func (r *Resolver) resolveEnv(envVar string) interface{} { - value := os.Getenv(envVar) - if value == "" { - return nil - } - return value -} - -// resolveGSM resolves a Google Secret Manager secret. -func (r *Resolver) resolveGSM(secretName string) interface{} { - if r.gcpProject == "" { - log.Printf("GCP project not configured for GSM resolution") - return nil - } - - // Initialize GSM client if needed - if r.gsmClient == nil { - ctx := context.Background() - client, err := secretmanager.NewClient(ctx) - if err != nil { - log.Printf("Failed to create GSM client: %v", err) - return nil - } - r.gsmClient = client - } - - // Build the resource name - name := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", r.gcpProject, secretName) - - // Access the secret - ctx := context.Background() - req := &secretmanagerpb.AccessSecretVersionRequest{ - Name: name, - } - - result, err := r.gsmClient.AccessSecretVersion(ctx, req) - if err != nil { - log.Printf("Failed to access GSM secret %s: %v", secretName, err) - return nil - } - - return string(result.Payload.Data) -} - -// resolveASM resolves an AWS Secrets Manager secret. -func (r *Resolver) resolveASM(secretName string) interface{} { - // Initialize AWS session if needed - if r.awsSession == nil { - sess, err := session.NewSession(&aws.Config{ - Region: aws.String(r.awsRegion), - }) - if err != nil { - log.Printf("Failed to create AWS session: %v", err) - return nil - } - r.awsSession = sess - } - - // Initialize ASM client if needed - if r.asmClient == nil { - r.asmClient = secretsmanager.New(r.awsSession) - } - - // Get the secret value - input := &secretsmanager.GetSecretValueInput{ - SecretId: aws.String(secretName), - } - - result, err := r.asmClient.GetSecretValue(input) - if err != nil { - log.Printf("Failed to access ASM secret %s: %v", secretName, err) - return nil - } - - // Return the secret string - if result.SecretString != nil { - return *result.SecretString - } - - // If it's binary data, we can't handle it as a string config value - log.Printf("ASM secret %s contains binary data, which is not supported", secretName) - return nil -} - -// resolveFile resolves a file's contents. -func (r *Resolver) resolveFile(filePath string) interface{} { - // Expand user home directory if present - if strings.HasPrefix(filePath, "~/") { - home, err := os.UserHomeDir() - if err != nil { - log.Printf("Failed to get user home directory: %v", err) - return nil - } - filePath = filepath.Join(home, filePath[2:]) - } - - data, err := afero.ReadFile(r.fs, filePath) - if err != nil { - log.Printf("Failed to read file %s: %v", filePath, err) - return nil - } - - // Strip whitespace/newlines from file contents - return strings.TrimSpace(string(data)) -} - -// Close closes any open clients. -func (r *Resolver) Close() error { - if r.gsmClient != nil { - return r.gsmClient.Close() - } - return nil -} diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 77b9048..b48cae1 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ -/*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */ -@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-500:oklch(.637 .237 25.331);--color-red-800:oklch(.444 .177 26.899);--color-gray-50:oklch(.985 .002 247.839);--color-gray-100:oklch(.967 .003 264.542);--color-gray-200:oklch(.928 .006 264.531);--color-gray-300:oklch(.872 .01 258.338);--color-gray-500:oklch(.551 .027 264.364);--color-gray-600:oklch(.446 .03 256.802);--color-gray-700:oklch(.373 .034 259.733);--color-gray-900:oklch(.21 .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-light:300;--font-weight-medium:500;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings);--color-primary-50:#e3f2fd;--color-primary-100:#bbdefb;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:color-mix(in oklab,currentColor 50%,transparent)}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-text{border-radius:var(--radius-md);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-600);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-text:hover{background-color:var(--color-primary-50)}}.btn-text:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-text:active{background-color:var(--color-primary-100)}.btn-text:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*3);color:var(--color-gray-900);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input::placeholder{color:var(--color-gray-500)}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing)*1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing)*4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing)*6);padding-block:calc(var(--spacing)*4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:color-mix(in oklab,var(--color-error-500)20%,transparent);background-color:var(--color-error-50);padding:calc(var(--spacing)*4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:color-mix(in oklab,var(--color-success-500)20%,transparent);background-color:var(--color-success-50);padding:calc(var(--spacing)*4);color:var(--color-success-700)}}@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.static{position:static}.mx-1{margin-inline:calc(var(--spacing)*1)}.mx-3{margin-inline:calc(var(--spacing)*3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-4{margin-right:calc(var(--spacing)*4)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.mb-10{margin-bottom:calc(var(--spacing)*10)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-16{height:calc(var(--spacing)*16)}.min-h-screen{min-height:100vh}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-16{width:calc(var(--spacing)*16)}.w-32{width:calc(var(--spacing)*32)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.transform{transform:var(--tw-rotate-x)var(--tw-rotate-y)var(--tw-rotate-z)var(--tw-skew-x)var(--tw-skew-y)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-success-50{background-color:var(--color-success-50)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-3{padding-block:calc(var(--spacing)*3)}.py-6{padding-block:calc(var(--spacing)*6)}.py-12{padding-block:calc(var(--spacing)*12)}.pt-4{padding-top:calc(var(--spacing)*4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-primary-500{color:var(--color-primary-500)}.text-success-500{color:var(--color-success-500)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-primary-600:hover{color:var(--color-primary-600)}}@media (width>=48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false;initial-value:rotateX(0)}@property --tw-rotate-y{syntax:"*";inherits:false;initial-value:rotateY(0)}@property --tw-rotate-z{syntax:"*";inherits:false;initial-value:rotateZ(0)}@property --tw-skew-x{syntax:"*";inherits:false;initial-value:skewX(0)}@property --tw-skew-y{syntax:"*";inherits:false;initial-value:skewY(0)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file +/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-light:300;--font-weight-medium:500;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-100:#bbdefb;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-text{border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-600);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-text:hover{background-color:var(--color-primary-50)}}.btn-text:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-text:active{background-color:var(--color-primary-100)}.btn-text:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}.overflow-x-auto{overflow-x:auto}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-success-50{background-color:var(--color-success-50)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-primary-600{color:var(--color-primary-600)}.text-red-600{color:var(--color-red-600)}.text-success-500{color:var(--color-success-500)}.text-yellow-600{color:var(--color-yellow-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-primary-600:hover{color:var(--color-primary-600)}.hover\:text-primary-700:hover{color:var(--color-primary-700)}}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/templates/source_detail.html b/templates/source_detail.html new file mode 100644 index 0000000..7e034f2 --- /dev/null +++ b/templates/source_detail.html @@ -0,0 +1,171 @@ +{{template "base" .}} + +{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to webhooks +
+
+

{{.Webhook.Name}}

+ {{if .Webhook.Description}} +

{{.Webhook.Description}}

+ {{end}} +
+
+ Event Log + Edit +
+ +
+
+
+
+ +
+ +
+
+

Entrypoints

+ +
+ + +
+
+ + +
+
+ +
+ {{range .Entrypoints}} +
+
+ {{if .Description}}{{.Description}}{{else}}Entrypoint{{end}} +
+ {{if .Active}} + Active + {{else}} + Inactive + {{end}} +
+ +
+
+ +
+
+
+ {{$.BaseURL}}/webhook/{{.Path}} +
+ {{else}} +
No entrypoints configured.
+ {{end}} +
+
+ + +
+
+

Targets

+ +
+ + +
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ +
+ {{range .Targets}} +
+
+ {{.Name}} +
+ {{.Type}} + {{if .Active}} + Active + {{else}} + Inactive + {{end}} +
+ +
+
+ +
+
+
+ {{if .Config}} + {{.Config}} + {{end}} +
+ {{else}} +
No targets configured.
+ {{end}} +
+
+
+ + +
+
+

Recent Events

+ View All +
+
+ {{range .Events}} +
+
+
+ {{.Method}} + {{.ContentType}} +
+ {{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}} +
+
+ {{else}} +
No events received yet.
+ {{end}} +
+
+ + +
+

Retention: {{.Webhook.RetentionDays}} days · Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}

+
+
+{{end}} diff --git a/templates/source_edit.html b/templates/source_edit.html new file mode 100644 index 0000000..365146a --- /dev/null +++ b/templates/source_edit.html @@ -0,0 +1,40 @@ +{{template "base" .}} + +{{define "title"}}Edit {{.Webhook.Name}} - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to {{.Webhook.Name}} +

Edit Webhook

+
+ +
+ {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+{{end}} diff --git a/templates/source_logs.html b/templates/source_logs.html new file mode 100644 index 0000000..2217466 --- /dev/null +++ b/templates/source_logs.html @@ -0,0 +1,61 @@ +{{template "base" .}} + +{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to {{.Webhook.Name}} +
+

Event Log

+ {{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}} +
+
+ +
+
+ {{range .Events}} +
+
+
+ {{.Method}} + {{.ID}} + {{.ContentType}} +
+
+ {{range .Deliveries}} + + {{.Target.Name}}: {{.Status}} + + {{end}} + {{.CreatedAt.Format "2006-01-02 15:04:05"}} + + + +
+
+ +
+
{{.Body}}
+
+
+ {{else}} +
No events recorded yet.
+ {{end}} +
+
+ + + {{if or .HasPrev .HasNext}} +
+ {{if .HasPrev}} + ← Previous + {{end}} + Page {{.Page}} of {{.TotalPages}} + {{if .HasNext}} + Next → + {{end}} +
+ {{end}} +
+{{end}} diff --git a/templates/sources_list.html b/templates/sources_list.html new file mode 100644 index 0000000..8d9a20c --- /dev/null +++ b/templates/sources_list.html @@ -0,0 +1,49 @@ +{{template "base" .}} + +{{define "title"}}Sources - Webhooker{{end}} + +{{define "content"}} + +{{end}} diff --git a/templates/sources_new.html b/templates/sources_new.html new file mode 100644 index 0000000..321ce8f --- /dev/null +++ b/templates/sources_new.html @@ -0,0 +1,41 @@ +{{template "base" .}} + +{{define "title"}}New Webhook - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to webhooks +

Create Webhook

+
+ +
+ {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + +

How long to keep event data.

+
+ +
+ + Cancel +
+
+
+
+{{end}} diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 0000000..a87ad61 --- /dev/null +++ b/templates/templates.go @@ -0,0 +1,8 @@ +package templates + +import ( + "embed" +) + +//go:embed *.html +var Templates embed.FS