Compare commits
9 Commits
b437955378
...
853f25ee67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853f25ee67 | ||
|
|
7d13c9da17 | ||
|
|
e6b79ce1be | ||
|
|
483d7f31ff | ||
|
|
3e3d44a168 | ||
|
|
d4eef6bd6a | ||
|
|
7bbe47b943 | ||
| b5cf4c3d2f | |||
| 011ec270c2 |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Jeffrey Paul <sneak@sneak.berlin>
|
||||||
|
|
||||||
|
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.
|
||||||
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: test lint fmt fmt-check check build run dev deps docker clean hooks
|
.PHONY: test lint fmt fmt-check check build run dev deps docker clean hooks css
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -41,3 +41,6 @@ hooks:
|
|||||||
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
|
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
|
||||||
@chmod +x .git/hooks/pre-commit
|
@chmod +x .git/hooks/pre-commit
|
||||||
@echo "pre-commit hook installed"
|
@echo "pre-commit hook installed"
|
||||||
|
|
||||||
|
css:
|
||||||
|
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
|||||||
801
README.md
801
README.md
@@ -1,188 +1,737 @@
|
|||||||
# webhooker
|
# webhooker
|
||||||
|
|
||||||
webhooker is a Go web application by [@sneak](https://sneak.berlin) that
|
webhooker is a self-hosted webhook proxy and store-and-forward service
|
||||||
receives, stores, and proxies webhooks to configured targets with retry
|
written in [Go](https://golang.org) by
|
||||||
support, observability, and a management web UI. License: pending.
|
[@sneak](https://sneak.berlin). It receives webhooks from external
|
||||||
|
services, durably stores them, and delivers them to configured targets
|
||||||
|
with retry support, logging, and observability. Category: infrastructure
|
||||||
|
/ web service. License: MIT.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.24+
|
||||||
|
- golangci-lint v1.64+
|
||||||
|
- Docker (for containerized deployment)
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# Clone the repo
|
||||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||||
cd webhooker
|
cd webhooker
|
||||||
|
|
||||||
# Install dependencies
|
# Install Go dependencies
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Copy example config
|
|
||||||
cp configs/config.yaml.example config.yaml
|
|
||||||
|
|
||||||
# Run in development mode
|
|
||||||
make dev
|
|
||||||
|
|
||||||
# Run all checks (format, lint, test, build)
|
# Run all checks (format, lint, test, build)
|
||||||
make check
|
make check
|
||||||
|
|
||||||
|
# Run in development mode (uses SQLite in current directory)
|
||||||
|
make dev
|
||||||
|
|
||||||
# Build Docker image
|
# Build Docker image
|
||||||
make docker
|
make docker
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Development Commands
|
||||||
|
|
||||||
- `WEBHOOKER_ENVIRONMENT` — `dev` or `prod` (default: `dev`)
|
```bash
|
||||||
- `DEBUG` — Enable debug logging
|
make fmt # Format code (gofmt + goimports)
|
||||||
- `PORT` — Server port (default: `8080`)
|
make lint # Run golangci-lint
|
||||||
- `DBURL` — Database connection string
|
make test # Run tests with race detection
|
||||||
- `SESSION_KEY` — Base64-encoded 32-byte session key (required in prod)
|
make check # fmt-check + lint + test + build (CI gate)
|
||||||
- `METRICS_USERNAME` — Username for metrics endpoint
|
make build # Build binary to bin/webhooker
|
||||||
- `METRICS_PASSWORD` — Password for metrics endpoint
|
make dev # go run ./cmd/webhooker
|
||||||
- `SENTRY_DSN` — Sentry error reporting (optional)
|
make docker # Build Docker image
|
||||||
|
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`).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| 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)* |
|
||||||
|
| `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
|
||||||
|
with a randomly generated password and logs it to stdout. This password
|
||||||
|
is only displayed once.
|
||||||
|
|
||||||
|
### Running with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v /path/to/data:/data \
|
||||||
|
-e DBURL="file:/data/webhooker.db?cache=shared&mode=rwc" \
|
||||||
|
-e SESSION_KEY="<base64-encoded-32-byte-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`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Webhook integrations between services are fragile: the receiving service
|
Webhook integrations between services are inherently fragile. The
|
||||||
must be up when the webhook fires, there is no built-in retry for most
|
receiving service must be online when the webhook fires, most webhook
|
||||||
webhook senders, and there is no visibility into what was sent or when.
|
senders provide no built-in retry mechanism, and there is no standard
|
||||||
webhooker solves this by acting as a reliable intermediary that receives
|
way to inspect what was sent, when it was sent, or whether delivery
|
||||||
webhooks, stores them, and delivers them to configured targets — with
|
succeeded.
|
||||||
optional retries, logging, and Prometheus metrics for observability.
|
|
||||||
|
|
||||||
Use cases include:
|
webhooker solves this by acting as a durable intermediary:
|
||||||
|
|
||||||
- Store-and-forward with unlimited retries for unreliable receivers
|
1. **Reliable ingestion** — webhooker is always ready to accept incoming
|
||||||
- Prometheus/Grafana metric analysis of webhook frequency, size, and
|
webhooks. It stores every received event before attempting any
|
||||||
handler performance
|
delivery, so nothing is lost if downstream targets are unavailable.
|
||||||
- Introspection and debugging of webhook payloads
|
|
||||||
- Redelivery of webhook events for application testing
|
2. **Guaranteed delivery** — Events are queued for delivery to each
|
||||||
- Fan-out delivery of webhooks to multiple targets
|
configured target. Failed deliveries are retried with configurable
|
||||||
- HA ingestion endpoint for delivery to less reliable systems
|
backoff. Every delivery attempt is logged with status codes, response
|
||||||
|
bodies, and timing.
|
||||||
|
|
||||||
|
3. **Observability** — Full request/response logging for every webhook
|
||||||
|
received and every delivery attempted. Prometheus metrics expose
|
||||||
|
volume, latency, and error rates. The web UI provides real-time
|
||||||
|
visibility into event flow.
|
||||||
|
|
||||||
|
4. **Fan-out** — A single incoming webhook can be delivered to multiple
|
||||||
|
targets simultaneously. This enables patterns like forwarding a
|
||||||
|
GitHub webhook to both a deployment service and a Slack channel.
|
||||||
|
|
||||||
|
5. **Replay** — Stored events can be manually redelivered for debugging
|
||||||
|
or testing, without requiring the original sender to fire the webhook
|
||||||
|
again.
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
- **Store-and-forward** with configurable retries for unreliable
|
||||||
|
receivers
|
||||||
|
- **Observability** via Prometheus metrics on webhook frequency, payload
|
||||||
|
size, and delivery performance
|
||||||
|
- **Debugging** and introspection of webhook payloads in the web UI
|
||||||
|
- **Replay** of webhook events for application testing and development
|
||||||
|
- **Fan-out** delivery of a single webhook to multiple downstream
|
||||||
|
targets
|
||||||
|
- **High-availability ingestion** for delivery to less reliable backend
|
||||||
|
systems
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
### Architecture
|
### Architecture Overview
|
||||||
|
|
||||||
webhooker uses Uber's fx dependency injection library for managing
|
webhooker is structured as a standard Go HTTP server following the
|
||||||
application lifecycle. It uses `log/slog` for structured logging, GORM
|
[sneak/prompts GO_HTTP_SERVER_CONVENTIONS](https://git.eeqj.de/sneak/prompts/src/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md).
|
||||||
for database access, and SQLite (via `modernc.org/sqlite`, pure Go, no
|
It uses:
|
||||||
CGO) for storage. HTTP routing uses chi.
|
|
||||||
|
|
||||||
### Rate Limiting
|
- **[Uber fx](https://go.uber.org/fx)** for dependency injection and
|
||||||
|
lifecycle management
|
||||||
|
- **[go-chi](https://github.com/go-chi/chi)** for HTTP routing
|
||||||
|
- **[GORM](https://gorm.io)** for database access with
|
||||||
|
**[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as
|
||||||
|
the runtime SQLite driver. Note: `gorm.io/driver/sqlite` transitively
|
||||||
|
depends on `mattn/go-sqlite3`, which requires CGO at build time (see
|
||||||
|
[Docker](#docker) section)
|
||||||
|
- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured
|
||||||
|
logging with TTY detection (text for dev, JSON for prod)
|
||||||
|
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for
|
||||||
|
encrypted cookie-based session management
|
||||||
|
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||||
|
`/metrics` behind basic auth
|
||||||
|
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||||
|
|
||||||
Global rate limiting middleware (e.g. per-IP throttling applied at the
|
### Naming Conventions
|
||||||
router level) must **not** apply to webhook receiver endpoints
|
|
||||||
(`/webhook/{uuid}`). Webhook endpoints receive automated traffic from
|
|
||||||
external services at unpredictable rates, and blanket rate limits would
|
|
||||||
cause legitimate webhook deliveries to be dropped.
|
|
||||||
|
|
||||||
Instead, each webhook endpoint has its own individually configurable rate
|
This README uses the target naming scheme for the application's core
|
||||||
limit, applied within the webhook handler itself. By default, no rate
|
entities. The current codebase uses older names that will be updated in
|
||||||
limit is applied — webhook endpoints accept traffic as fast as it arrives.
|
a future refactor (see
|
||||||
Rate limits can be configured on a per-webhook basis in the application
|
[issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)):
|
||||||
when needed (e.g. to protect against a misbehaving sender).
|
|
||||||
|
|
||||||
### Database Architecture
|
| 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 |
|
||||||
|
|
||||||
webhooker uses separate SQLite database files rather than a single
|
Throughout this document, the target names are used. The code rename is
|
||||||
monolithic database:
|
tracked separately.
|
||||||
|
|
||||||
- **Main application database** — Stores application configuration and
|
|
||||||
all standard webapp data: users, sessions, API keys, and global
|
|
||||||
settings.
|
|
||||||
- **Per-processor databases** — Each processor (working name — a better
|
|
||||||
term is needed) gets its own dedicated SQLite database file containing:
|
|
||||||
input logs, processor logs, and all output queues for that specific
|
|
||||||
processor.
|
|
||||||
|
|
||||||
This separation provides several benefits: processor databases can be
|
|
||||||
independently backed up, rotated, or archived; a high-volume processor
|
|
||||||
won't cause lock contention or bloat affecting the main application; and
|
|
||||||
individual processor data can be cleanly deleted when a processor is
|
|
||||||
removed.
|
|
||||||
|
|
||||||
### Package Layout
|
|
||||||
|
|
||||||
All application code lives under `internal/` to prevent external imports.
|
|
||||||
The main entry point is `cmd/webhooker/main.go`.
|
|
||||||
|
|
||||||
- `internal/config` — Configuration management via `pkg/config` (Viper-based)
|
|
||||||
- `internal/database` — GORM database connection, migrations, and models
|
|
||||||
- `internal/globals` — Global application metadata (version, build info)
|
|
||||||
- `internal/handlers` — HTTP handlers using the closure pattern
|
|
||||||
- `internal/healthcheck` — Health check endpoint logic
|
|
||||||
- `internal/logger` — Structured logging setup (`log/slog`)
|
|
||||||
- `internal/middleware` — HTTP middleware (auth, CORS, logging, metrics)
|
|
||||||
- `internal/server` — HTTP server setup and routing
|
|
||||||
- `internal/session` — Session management (gorilla/sessions)
|
|
||||||
- `pkg/config` — Reusable multi-environment configuration library
|
|
||||||
- `static/` — Embedded static assets (CSS, JS)
|
|
||||||
- `templates/` — Go HTML templates
|
|
||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
- **Users** — Service users with username/password (Argon2id hashing)
|
webhooker's data model has eight entities organized into two tiers: the
|
||||||
- **Processors** — Webhook processing units, many-to-one with users
|
**application tier** (user and webhook configuration) and the **event
|
||||||
- **Webhooks** — Inbound URL endpoints feeding into processors
|
tier** (event ingestion, delivery, and logging).
|
||||||
- **Targets** — Delivery destinations per processor (HTTP, retry, database, log)
|
|
||||||
- **Events** — Captured webhook payloads
|
```
|
||||||
- **Deliveries** — Pairing of events with targets
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
- **Delivery Results** — Outcome of each delivery attempt
|
│ APPLICATION TIER │
|
||||||
- **API Keys** — Programmatic access credentials per user
|
│ (main application database) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||||
|
│ │ User │──1:N──│ Webhook │──1:N──│ Entrypoint │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ │ │ │──1:N──│ Target │ │
|
||||||
|
│ │ │ └──────────┘ └──────────────┘ │
|
||||||
|
│ │ │──1:N──│ APIKey │ │
|
||||||
|
│ └──────────┘ └──────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ EVENT TIER │
|
||||||
|
│ (planned: per-webhook dedicated database) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||||
|
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User
|
||||||
|
|
||||||
|
A registered user of the webhooker service.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------- | -------- | ----------- |
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `username` | string | Unique login name |
|
||||||
|
| `password` | string | Argon2id hash (never exposed via API) |
|
||||||
|
|
||||||
|
**Relations:** Has many Webhooks. Has many APIKeys.
|
||||||
|
|
||||||
|
Passwords are hashed with Argon2id using secure defaults (64 MB memory,
|
||||||
|
1 iteration, 4 threads, 32-byte key, 16-byte salt). On first startup,
|
||||||
|
an `admin` user is created with a randomly generated 16-character
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------- | ------- | ----------- |
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `user_id` | UUID | Foreign key → User |
|
||||||
|
| `name` | string | Human-readable name |
|
||||||
|
| `description` | string | Optional description |
|
||||||
|
| `retention_days` | integer | Days to retain events (default: 30) |
|
||||||
|
|
||||||
|
**Relations:** Belongs to User. Has many Entrypoints. Has many Targets.
|
||||||
|
|
||||||
|
The `retention_days` field controls how long event data is kept in the
|
||||||
|
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.
|
||||||
|
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 |
|
||||||
|
| `path` | string | Unique URL path (UUID-based, e.g. `/webhook/{uuid}`) |
|
||||||
|
| `description` | string | Optional description |
|
||||||
|
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||||
|
|
||||||
|
**Relations:** Belongs to Webhook.
|
||||||
|
|
||||||
|
A webhook can have multiple entrypoints. This allows separate URLs for
|
||||||
|
different event sources that all feed into the same processing pipeline
|
||||||
|
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
|
||||||
|
the same targets).
|
||||||
|
|
||||||
|
#### Target
|
||||||
|
|
||||||
|
A delivery destination for events. Each target defines where and how
|
||||||
|
events should be forwarded.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------- | ---------- | ----------- |
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `processor_id` | UUID | Foreign key → Webhook |
|
||||||
|
| `name` | string | Human-readable name |
|
||||||
|
| `type` | TargetType | One of: `http`, `retry`, `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) |
|
||||||
|
|
||||||
|
**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.
|
||||||
|
- **`log`** — Write the event to the application log (stdout). Useful
|
||||||
|
for debugging.
|
||||||
|
|
||||||
|
The `config` field stores type-specific configuration as JSON (e.g.,
|
||||||
|
destination URL, custom headers, timeout settings).
|
||||||
|
|
||||||
|
#### APIKey
|
||||||
|
|
||||||
|
A programmatic access credential for API authentication.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------- | --------- | ----------- |
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `user_id` | UUID | Foreign key → User |
|
||||||
|
| `key` | string | Unique API key value |
|
||||||
|
| `description` | string | Optional description |
|
||||||
|
| `last_used_at` | timestamp | Last time this key was used (nullable) |
|
||||||
|
|
||||||
|
**Relations:** Belongs to User.
|
||||||
|
|
||||||
|
#### Event
|
||||||
|
|
||||||
|
A captured incoming webhook request. Stores the complete HTTP request
|
||||||
|
data for replay and auditing.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------- | ------ | ----------- |
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `processor_id` | UUID | Foreign key → Webhook |
|
||||||
|
| `webhook_id` | UUID | Foreign key → Entrypoint |
|
||||||
|
| `method` | string | HTTP method (POST, PUT, etc.) |
|
||||||
|
| `headers` | JSON | Complete request headers |
|
||||||
|
| `body` | text | Raw request body |
|
||||||
|
| `content_type` | string | Content-Type header value |
|
||||||
|
|
||||||
|
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
||||||
|
Deliveries.
|
||||||
|
|
||||||
|
When a request arrives at an entrypoint, the full request (method,
|
||||||
|
headers, body) is captured as an Event. The event is then queued for
|
||||||
|
delivery to every active target configured on the parent webhook.
|
||||||
|
|
||||||
|
#### Delivery
|
||||||
|
|
||||||
|
The pairing of an event with a target. Tracks the overall delivery
|
||||||
|
status across potentially multiple attempts.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------- | -------------- | ----------- |
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `event_id` | UUID | Foreign key → Event |
|
||||||
|
| `target_id`| UUID | Foreign key → Target |
|
||||||
|
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||||
|
|
||||||
|
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||||
|
DeliveryResults.
|
||||||
|
|
||||||
|
**Delivery statuses:**
|
||||||
|
|
||||||
|
- **`pending`** — Created but not yet attempted.
|
||||||
|
- **`retrying`** — At least one attempt failed; more attempts remain.
|
||||||
|
- **`delivered`** — Successfully delivered (at least one attempt
|
||||||
|
succeeded).
|
||||||
|
- **`failed`** — All retry attempts exhausted without success.
|
||||||
|
|
||||||
|
#### DeliveryResult
|
||||||
|
|
||||||
|
The result of a single delivery attempt. Every attempt (including
|
||||||
|
retries) is individually logged for full observability.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------------- | ------- | ----------- |
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `delivery_id` | UUID | Foreign key → Delivery |
|
||||||
|
| `attempt_num` | integer | Attempt number (1-based) |
|
||||||
|
| `success` | boolean | Whether this attempt succeeded |
|
||||||
|
| `status_code` | integer | HTTP response status code (if applicable) |
|
||||||
|
| `response_body` | text | Response body (if applicable) |
|
||||||
|
| `error` | string | Error message (on failure) |
|
||||||
|
| `duration` | integer | Request duration in milliseconds |
|
||||||
|
|
||||||
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
|
#### Common Fields
|
||||||
|
|
||||||
|
All entities include these fields from `BaseModel`:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | --------- | ----------- |
|
||||||
|
| `id` | UUID | Auto-generated UUIDv4 primary key |
|
||||||
|
| `created_at` | timestamp | Record creation time |
|
||||||
|
| `updated_at` | timestamp | Last modification time |
|
||||||
|
| `deleted_at` | timestamp | Soft-delete timestamp (nullable; GORM soft deletes) |
|
||||||
|
|
||||||
|
### Database Architecture
|
||||||
|
|
||||||
|
#### Current Implementation
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### 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:
|
||||||
|
|
||||||
|
- **Users** — accounts and Argon2id password hashes
|
||||||
|
- **Webhooks** (Processors) — webhook configurations
|
||||||
|
- **Entrypoints** (Webhooks) — 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:
|
||||||
|
|
||||||
|
- **Events** — captured incoming webhook payloads
|
||||||
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
|
|
||||||
|
This planned separation will provide:
|
||||||
|
|
||||||
|
- **Isolation** — a high-volume webhook won't cause lock contention or
|
||||||
|
WAL bloat affecting the main application or other webhooks.
|
||||||
|
- **Independent lifecycle** — event databases can be independently
|
||||||
|
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.
|
||||||
|
- **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
|
||||||
|
webhooks won't contend.
|
||||||
|
|
||||||
|
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
|
||||||
|
`mattn/go-sqlite3` dependency from `gorm.io/driver/sqlite`.
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
External Service
|
||||||
|
│
|
||||||
|
│ POST /webhook/{uuid}
|
||||||
|
▼
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ chi Router │────►│ Middleware │────►│ Webhook │
|
||||||
|
│ │ │ Stack │ │ Handler │
|
||||||
|
└─────────────┘ └──────────────┘ └──────┬───────┘
|
||||||
|
│
|
||||||
|
1. Look up Entrypoint by UUID
|
||||||
|
2. Capture full request as Event
|
||||||
|
3. Queue Delivery to each active Target
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ Delivery │
|
||||||
|
│ Engine │
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
┌────────────────────┼────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||||
|
│ HTTP Target│ │Retry Target│ │ Log Target │
|
||||||
|
│ (1 attempt)│ │ (backoff) │ │ (stdout) │
|
||||||
|
└────────────┘ └────────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Global rate limiting middleware (e.g., per-IP throttling applied at the
|
||||||
|
router level) **must not** apply to webhook receiver endpoints. Webhook
|
||||||
|
endpoints receive automated traffic from external services at
|
||||||
|
unpredictable rates, and blanket rate limits would cause legitimate
|
||||||
|
deliveries to be dropped.
|
||||||
|
|
||||||
|
Instead, each webhook has its own individually configurable rate limit,
|
||||||
|
applied within the webhook handler itself. By default, no rate limit is
|
||||||
|
applied — webhook endpoints accept traffic as fast as it arrives. Rate
|
||||||
|
limits can be configured per-webhook when needed (e.g., to protect
|
||||||
|
against a misbehaving sender).
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
- `GET /` — Web UI index page
|
#### Public Endpoints
|
||||||
- `GET /.well-known/healthcheck` — Health check with uptime, version
|
|
||||||
- `GET /s/*` — Static file serving (CSS, JS)
|
| Method | Path | Description |
|
||||||
- `GET /metrics` — Prometheus metrics (requires basic auth)
|
| ------ | --------------------------- | ----------- |
|
||||||
- `POST /webhook/{uuid}` — Webhook receiver endpoint
|
| `GET` | `/` | Web UI index page (server-rendered) |
|
||||||
- `/pages/login`, `/pages/logout` — Authentication
|
| `GET` | `/.well-known/healthcheck` | Health check (JSON: status, uptime, version) |
|
||||||
- `/user/{username}` — User profile
|
| `GET` | `/s/*` | Static file serving (embedded CSS, JS) |
|
||||||
- `/sources/*` — Webhook source management
|
| `ANY` | `/webhook/{uuid}` | Webhook receiver endpoint (accepts all methods) |
|
||||||
|
|
||||||
|
#### Authentication Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------- | ----------- |
|
||||||
|
| `GET` | `/pages/login` | Login page |
|
||||||
|
| `POST` | `/pages/login` | Login form submission |
|
||||||
|
| `POST` | `/pages/logout` | Logout (destroys session) |
|
||||||
|
|
||||||
|
#### Authenticated Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------------------ | ----------- |
|
||||||
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
|
| `GET` | `/sources` | List user's webhooks |
|
||||||
|
| `GET` | `/sources/new` | Create webhook form |
|
||||||
|
| `POST` | `/sources/new` | Create webhook submission |
|
||||||
|
| `GET` | `/source/{id}` | Webhook detail view |
|
||||||
|
| `GET` | `/source/{id}/edit` | Edit webhook form |
|
||||||
|
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
||||||
|
| `POST` | `/source/{id}/delete` | Delete webhook |
|
||||||
|
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
||||||
|
|
||||||
|
#### Infrastructure Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---------- | ----------- |
|
||||||
|
| `GET` | `/metrics` | Prometheus metrics (requires basic auth) |
|
||||||
|
|
||||||
|
#### API (Planned)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| -------- | ------------------------------ | ----------- |
|
||||||
|
| `GET` | `/api/v1/webhooks` | List webhooks |
|
||||||
|
| `POST` | `/api/v1/webhooks` | Create webhook |
|
||||||
|
| `GET` | `/api/v1/webhooks/{id}` | Get webhook details |
|
||||||
|
| `PUT` | `/api/v1/webhooks/{id}` | Update webhook |
|
||||||
|
| `DELETE` | `/api/v1/webhooks/{id}` | Delete webhook |
|
||||||
|
| `GET` | `/api/v1/webhooks/{id}/events` | List events for webhook |
|
||||||
|
| `POST` | `/api/v1/events/{id}/redeliver`| Redeliver an event |
|
||||||
|
|
||||||
|
API authentication will use API keys passed via `Authorization: Bearer
|
||||||
|
<key>` header.
|
||||||
|
|
||||||
|
### Package Layout
|
||||||
|
|
||||||
|
All application code lives under `internal/` to prevent external
|
||||||
|
imports. The entry point is `cmd/webhooker/main.go`.
|
||||||
|
|
||||||
|
```
|
||||||
|
webhooker/
|
||||||
|
├── cmd/webhooker/
|
||||||
|
│ └── main.go # Entry point: sets globals, wires fx
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── config.go # Configuration loading via pkg/config
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── base_model.go # BaseModel with UUID primary keys
|
||||||
|
│ │ ├── database.go # GORM connection, migrations, admin seed
|
||||||
|
│ │ ├── models.go # AutoMigrate for all models
|
||||||
|
│ │ ├── model_user.go # User entity
|
||||||
|
│ │ ├── model_processor.go # Webhook entity (to be renamed)
|
||||||
|
│ │ ├── model_webhook.go # Entrypoint entity (to be renamed)
|
||||||
|
│ │ ├── 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_apikey.go # APIKey entity
|
||||||
|
│ │ └── password.go # Argon2id hashing and verification
|
||||||
|
│ ├── globals/
|
||||||
|
│ │ └── globals.go # Build-time variables (appname, version, arch)
|
||||||
|
│ ├── 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)
|
||||||
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
|
│ ├── healthcheck/
|
||||||
|
│ │ └── healthcheck.go # Health check service (uptime, version)
|
||||||
|
│ ├── logger/
|
||||||
|
│ │ └── logger.go # slog setup with TTY detection
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── middleware.go # Logging, CORS, Auth, Metrics, MetricsAuth
|
||||||
|
│ ├── server/
|
||||||
|
│ │ ├── server.go # Server struct, fx lifecycle, signal handling
|
||||||
|
│ │ ├── http.go # HTTP server setup with timeouts
|
||||||
|
│ │ └── 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
|
||||||
|
└── .golangci.yml # Linter configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The server starts via `fx.Invoke(func(*server.Server) {})` which
|
||||||
|
triggers the fx lifecycle hooks in dependency order.
|
||||||
|
|
||||||
|
### Middleware Stack
|
||||||
|
|
||||||
|
Applied to all routes in this order:
|
||||||
|
|
||||||
|
1. **Recoverer** — Panic recovery (chi built-in)
|
||||||
|
2. **RequestID** — Generate unique request IDs (chi built-in)
|
||||||
|
3. **Logging** — Structured request logging (method, URL, status,
|
||||||
|
latency, remote IP, user agent, request ID)
|
||||||
|
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` is set)
|
||||||
|
5. **CORS** — Cross-origin resource sharing headers
|
||||||
|
6. **Timeout** — 60-second request timeout
|
||||||
|
7. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set;
|
||||||
|
configured with `Repanic: true` so panics still reach Recoverer)
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- **Web UI:** Cookie-based sessions using gorilla/sessions with
|
||||||
|
encrypted cookies. Sessions are configured with HttpOnly, SameSite
|
||||||
|
Lax, and Secure (in production). Session lifetime is 7 days.
|
||||||
|
- **API (planned):** API key authentication via `Authorization: Bearer`
|
||||||
|
header. API keys are stored per-user with usage tracking
|
||||||
|
(`last_used_at`).
|
||||||
|
- **Metrics:** Basic authentication protecting the `/metrics` endpoint.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- Prometheus metrics behind basic auth
|
||||||
|
- Static assets embedded in binary (no filesystem access needed at
|
||||||
|
runtime)
|
||||||
|
- Container runs as non-root user (UID 1000)
|
||||||
|
- GORM soft deletes on all entities (data preserved for audit)
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The builder uses Debian rather than Alpine because GORM's SQLite
|
||||||
|
dialect pulls in CGO-dependent headers at compile time. The runtime
|
||||||
|
binary is statically linked and runs on Alpine.
|
||||||
|
|
||||||
|
`docker build .` is the CI gate — if it passes, the code is formatted,
|
||||||
|
linted, tested, and compiled.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
### Phase 1: Security & Infrastructure
|
### Phase 1: Core Webhook Engine
|
||||||
- [ ] Security headers (HSTS, CSP, X-Frame-Options)
|
- [ ] Implement webhook reception and event storage at `/webhook/{uuid}`
|
||||||
- [ ] Rate limiting middleware
|
- [ ] Build event processing and target delivery engine
|
||||||
- [ ] CSRF protection for forms
|
- [ ] Implement HTTP target type (fire-and-forget POST)
|
||||||
- [ ] Request ID tracking through entire lifecycle
|
- [ ] Implement retry target type (exponential backoff)
|
||||||
|
- [ ] Implement database target type (store only)
|
||||||
### Phase 2: Authentication & Authorization
|
- [ ] Implement log target type (console output)
|
||||||
- [ ] Authentication middleware for protected routes
|
- [ ] Per-webhook rate limiting in the receiver handler
|
||||||
- [ ] Session expiration and "remember me"
|
|
||||||
- [ ] Password reset flow
|
|
||||||
- [ ] API key authentication for programmatic access
|
|
||||||
|
|
||||||
### Phase 3: Core Webhook Features
|
|
||||||
- [ ] Webhook reception and event storage at `/webhook/{uuid}`
|
|
||||||
- [ ] Event processing and target delivery engine
|
|
||||||
- [ ] HTTP target type (fire-and-forget POST)
|
|
||||||
- [ ] Retry target type (exponential backoff)
|
|
||||||
- [ ] Database target type (store only)
|
|
||||||
- [ ] Log target type (console output)
|
|
||||||
- [ ] Webhook signature verification (GitHub, Stripe formats)
|
- [ ] Webhook signature verification (GitHub, Stripe formats)
|
||||||
|
|
||||||
|
### Phase 2: Database Separation
|
||||||
|
- [ ] Split into main application DB + per-webhook event DBs
|
||||||
|
- [ ] Automatic event retention cleanup based on `retention_days`
|
||||||
|
- [ ] Per-webhook database lifecycle management (create on webhook
|
||||||
|
creation, delete on webhook removal)
|
||||||
|
|
||||||
|
### Phase 3: Security & Infrastructure
|
||||||
|
- [ ] Implement authentication middleware for protected routes
|
||||||
|
([#9](https://git.eeqj.de/sneak/webhooker/issues/9))
|
||||||
|
- [ ] Security headers (HSTS, CSP, X-Frame-Options)
|
||||||
|
- [ ] CSRF protection for forms
|
||||||
|
- [ ] Session expiration and "remember me"
|
||||||
|
- [ ] Password change/reset flow
|
||||||
|
- [ ] API key authentication for programmatic access
|
||||||
|
|
||||||
### Phase 4: Web UI
|
### Phase 4: Web UI
|
||||||
- [ ] Webhook source management pages (list, create, edit, delete)
|
- [ ] Webhook management pages (list, create, edit, delete)
|
||||||
- [ ] Webhook request log viewer with filtering
|
- [ ] Webhook request log viewer with filtering
|
||||||
- [ ] Delivery status and retry management UI
|
- [ ] Delivery status and retry management UI
|
||||||
- [ ] Manual event redelivery
|
- [ ] Manual event redelivery
|
||||||
- [ ] Analytics dashboard (success rates, response times)
|
- [ ] Analytics dashboard (success rates, response times)
|
||||||
|
- [ ] Replace Bootstrap with Tailwind CSS + Alpine.js
|
||||||
|
([#4](https://git.eeqj.de/sneak/webhooker/issues/4))
|
||||||
|
|
||||||
### Phase 5: API
|
### Phase 5: REST API
|
||||||
- [ ] RESTful CRUD for processors, webhooks, targets
|
- [ ] RESTful CRUD for webhooks, entrypoints, targets
|
||||||
- [ ] Event viewing and filtering endpoints
|
- [ ] Event viewing and filtering endpoints
|
||||||
- [ ] API documentation (OpenAPI)
|
- [ ] 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
|
### Future
|
||||||
- [ ] Email source and delivery target types
|
- [ ] Email delivery target type
|
||||||
- [ ] SNS, S3, Slack delivery targets
|
- [ ] SNS, S3, Slack delivery targets
|
||||||
- [ ] Data transformations (e.g. webhook-to-Slack message)
|
- [ ] Data transformations (e.g., webhook-to-Slack message formatting)
|
||||||
- [ ] JSONL file delivery with periodic S3 upload
|
- [ ] JSONL file delivery with periodic S3 upload
|
||||||
|
- [ ] Webhook event search and filtering
|
||||||
|
- [ ] Multi-user with role-based access
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Pending — to be determined by the author (MIT, GPL, or WTFPL).
|
MIT
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
pkgconfig "sneak.berlin/go/webhooker/pkg/config"
|
pkgconfig "sneak.berlin/go/webhooker/pkg/config"
|
||||||
|
|
||||||
// spooky action at a distance!
|
// Populates the environment from a ./.env file automatically for
|
||||||
// this populates the environment
|
// development configuration. Kept in one place only (here).
|
||||||
// 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"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +61,40 @@ func (c *Config) IsProd() bool {
|
|||||||
return c.Environment == EnvironmentProd
|
return c.Environment == EnvironmentProd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envString returns the env var value if set, otherwise falls back to pkgconfig.
|
||||||
|
func envString(envKey, configKey string) string {
|
||||||
|
if v := os.Getenv(envKey); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return pkgconfig.GetString(configKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// envSecretString returns the env var value if set, otherwise falls back to pkgconfig secrets.
|
||||||
|
func envSecretString(envKey, configKey string) string {
|
||||||
|
if v := os.Getenv(envKey); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return pkgconfig.GetSecretString(configKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// envBool returns the env var value parsed as bool, otherwise falls back to pkgconfig.
|
||||||
|
func envBool(envKey, configKey string) bool {
|
||||||
|
if v := os.Getenv(envKey); v != "" {
|
||||||
|
return strings.EqualFold(v, "true") || v == "1"
|
||||||
|
}
|
||||||
|
return pkgconfig.GetBool(configKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// envInt returns the env var value parsed as int, otherwise falls back to pkgconfig.
|
||||||
|
func envInt(envKey, configKey string, defaultValue ...int) int {
|
||||||
|
if v := os.Getenv(envKey); v != "" {
|
||||||
|
if i, err := strconv.Atoi(v); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pkgconfig.GetInt(configKey, defaultValue...)
|
||||||
|
}
|
||||||
|
|
||||||
// nolint:revive // lc parameter is required by fx even if unused
|
// nolint:revive // lc parameter is required by fx even if unused
|
||||||
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||||
log := params.Logger.Get()
|
log := params.Logger.Get()
|
||||||
@@ -80,30 +111,30 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
EnvironmentDev, EnvironmentProd, environment)
|
EnvironmentDev, EnvironmentProd, environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the environment in the config package
|
// Set the environment in the config package (for fallback resolution)
|
||||||
pkgconfig.SetEnvironment(environment)
|
pkgconfig.SetEnvironment(environment)
|
||||||
|
|
||||||
// Load configuration values
|
// Load configuration values — env vars take precedence over config.yaml
|
||||||
s := &Config{
|
s := &Config{
|
||||||
DBURL: pkgconfig.GetString("dburl"),
|
DBURL: envString("DBURL", "dburl"),
|
||||||
Debug: pkgconfig.GetBool("debug"),
|
Debug: envBool("DEBUG", "debug"),
|
||||||
MaintenanceMode: pkgconfig.GetBool("maintenanceMode"),
|
MaintenanceMode: envBool("MAINTENANCE_MODE", "maintenanceMode"),
|
||||||
DevelopmentMode: pkgconfig.GetBool("developmentMode"),
|
DevelopmentMode: envBool("DEVELOPMENT_MODE", "developmentMode"),
|
||||||
DevAdminUsername: pkgconfig.GetString("devAdminUsername"),
|
DevAdminUsername: envString("DEV_ADMIN_USERNAME", "devAdminUsername"),
|
||||||
DevAdminPassword: pkgconfig.GetString("devAdminPassword"),
|
DevAdminPassword: envString("DEV_ADMIN_PASSWORD", "devAdminPassword"),
|
||||||
Environment: pkgconfig.GetString("environment", environment),
|
Environment: environment,
|
||||||
MetricsUsername: pkgconfig.GetString("metricsUsername"),
|
MetricsUsername: envString("METRICS_USERNAME", "metricsUsername"),
|
||||||
MetricsPassword: pkgconfig.GetString("metricsPassword"),
|
MetricsPassword: envString("METRICS_PASSWORD", "metricsPassword"),
|
||||||
Port: pkgconfig.GetInt("port", 8080),
|
Port: envInt("PORT", "port", 8080),
|
||||||
SentryDSN: pkgconfig.GetSecretString("sentryDSN"),
|
SentryDSN: envSecretString("SENTRY_DSN", "sentryDSN"),
|
||||||
SessionKey: pkgconfig.GetSecretString("sessionKey"),
|
SessionKey: envSecretString("SESSION_KEY", "sessionKey"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate database URL
|
// Validate database URL
|
||||||
if s.DBURL == "" {
|
if s.DBURL == "" {
|
||||||
return nil, fmt.Errorf("database URL (dburl) is required")
|
return nil, fmt.Errorf("database URL (DBURL) is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, require session key
|
// In production, require session key
|
||||||
@@ -118,8 +149,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
|
|
||||||
if s.Debug {
|
if s.Debug {
|
||||||
params.Logger.EnableDebugLogging()
|
params.Logger.EnableDebugLogging()
|
||||||
s.log = params.Logger.Get()
|
|
||||||
log.Debug("Debug mode enabled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log configuration summary (without secrets)
|
// Log configuration summary (without secrets)
|
||||||
|
|||||||
14
internal/database/model_entrypoint.go
Normal file
14
internal/database/model_entrypoint.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
// Event represents a webhook event
|
// Event represents a captured webhook event
|
||||||
type Event struct {
|
type Event struct {
|
||||||
BaseModel
|
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
|
// Request data
|
||||||
Method string `gorm:"not null" json:"method"`
|
Method string `gorm:"not null" json:"method"`
|
||||||
@@ -14,7 +14,7 @@ type Event struct {
|
|||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Processor Processor `json:"processor,omitempty"`
|
|
||||||
Webhook Webhook `json:"webhook,omitempty"`
|
Webhook Webhook `json:"webhook,omitempty"`
|
||||||
|
Entrypoint Entrypoint `json:"entrypoint,omitempty"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,14 @@ const (
|
|||||||
TargetTypeLog TargetType = "log"
|
TargetTypeLog TargetType = "log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Target represents a delivery target for a processor
|
// Target represents a delivery target for a webhook
|
||||||
type Target struct {
|
type Target struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Type TargetType `gorm:"not null" json:"type"`
|
Type TargetType `gorm:"not null" json:"type"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
|
||||||
// Configuration fields (JSON stored based on type)
|
// Configuration fields (JSON stored based on type)
|
||||||
Config string `gorm:"type:text" json:"config"` // JSON configuration
|
Config string `gorm:"type:text" json:"config"` // JSON configuration
|
||||||
@@ -27,6 +27,6 @@ type Target struct {
|
|||||||
MaxQueueSize int `json:"max_queue_size,omitempty"`
|
MaxQueueSize int `json:"max_queue_size,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Processor Processor `json:"processor,omitempty"`
|
Webhook Webhook `json:"webhook,omitempty"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ type User struct {
|
|||||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Processors []Processor `json:"processors,omitempty"`
|
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package database
|
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 {
|
type Webhook struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
|
UserID string `gorm:"type:uuid;not null" json:"user_id"`
|
||||||
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this webhook
|
Name string `gorm:"not null" json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
RetentionDays int `gorm:"default:30" json:"retention_days"` // Days to retain events
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Processor Processor `json:"processor,omitempty"`
|
User User `json:"user,omitempty"`
|
||||||
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||||
|
Targets []Target `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ func (d *Database) Migrate() error {
|
|||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
&User{},
|
&User{},
|
||||||
&APIKey{},
|
&APIKey{},
|
||||||
&Processor{},
|
|
||||||
&Webhook{},
|
&Webhook{},
|
||||||
|
&Entrypoint{},
|
||||||
&Target{},
|
&Target{},
|
||||||
&Event{},
|
&Event{},
|
||||||
&Delivery{},
|
&Delivery{},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|||||||
"Error": "",
|
"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",
|
"Error": "Username and password are required",
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
"Error": "Invalid username or password",
|
"Error": "Invalid username or password",
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
"Error": "Invalid username or password",
|
"Error": "Invalid username or password",
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
"sneak.berlin/go/webhooker/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // HandlersParams is a standard fx naming convention
|
// nolint:revive // HandlersParams is a standard fx naming convention
|
||||||
@@ -26,11 +27,20 @@ type HandlersParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
params *HandlersParams
|
params *HandlersParams
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
db *database.Database
|
db *database.Database
|
||||||
session *session.Session
|
session *session.Session
|
||||||
|
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.
|
||||||
|
func parsePageTemplate(pageFile string) *template.Template {
|
||||||
|
return template.Must(
|
||||||
|
template.ParseFS(templates.Templates, "htmlheader.html", "navbar.html", "base.html", pageFile),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||||
@@ -40,9 +50,16 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
|||||||
s.hc = params.Healthcheck
|
s.hc = params.Healthcheck
|
||||||
s.db = params.Database
|
s.db = params.Database
|
||||||
s.session = params.Session
|
s.session = params.Session
|
||||||
|
|
||||||
|
// 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"),
|
||||||
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
// FIXME compile some templates here or something
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -80,16 +97,11 @@ type UserInfo struct {
|
|||||||
Username string
|
Username string
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTemplate renders a template with common data
|
// renderTemplate renders a pre-parsed template with common data
|
||||||
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templateFiles []string, data interface{}) {
|
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) {
|
||||||
// Always include the common templates
|
tmpl, ok := s.templates[pageTemplate]
|
||||||
allTemplates := []string{"templates/htmlheader.html", "templates/navbar.html"}
|
if !ok {
|
||||||
allTemplates = append(allTemplates, templateFiles...)
|
s.log.Error("template not found", "template", pageTemplate)
|
||||||
|
|
||||||
// Parse templates
|
|
||||||
tmpl, err := template.ParseFiles(allTemplates...)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to parse template", "error", err)
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,6 +120,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
|
// Wrap data with base template data
|
||||||
type templateDataWrapper struct {
|
type templateDataWrapper struct {
|
||||||
User *UserInfo
|
User *UserInfo
|
||||||
@@ -119,17 +141,6 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa
|
|||||||
Data: data,
|
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 {
|
if err := tmpl.Execute(w, wrapper); err != nil {
|
||||||
s.log.Error("failed to execute template", "error", err)
|
s.log.Error("failed to execute template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -87,10 +87,11 @@ func TestRenderTemplate(t *testing.T) {
|
|||||||
"Version": "1.0.0",
|
"Version": "1.0.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
// When templates don't exist, renderTemplate should return an error
|
// When a non-existent template name is requested, renderTemplate
|
||||||
h.renderTemplate(w, req, []string{"nonexistent.html"}, data)
|
// 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)
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (s *Handlers) HandleIndex() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render the template
|
// Render the template
|
||||||
s.renderTemplate(w, req, []string{"templates/base.html", "templates/index.html"}, data)
|
s.renderTemplate(w, req, "index.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,6 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render the profile page
|
// Render the profile page
|
||||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data)
|
h.renderTemplate(w, r, "profile.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,66 +4,66 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleSourceList shows a list of user's webhook sources
|
// HandleSourceList shows a list of user's webhooks
|
||||||
func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source list page
|
// TODO: Implement webhook list page
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source creation form
|
// TODO: Implement webhook creation form
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceCreateSubmit handles the source creation form submission
|
// HandleSourceCreateSubmit handles the webhook creation form submission
|
||||||
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source creation logic
|
// TODO: Implement webhook creation logic
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceDetail shows details for a specific webhook source
|
// HandleSourceDetail shows details for a specific webhook
|
||||||
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source detail page
|
// TODO: Implement webhook detail page
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceEdit shows the form to edit a webhook source
|
// HandleSourceEdit shows the form to edit a webhook
|
||||||
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source edit form
|
// TODO: Implement webhook edit form
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceEditSubmit handles the source edit form submission
|
// HandleSourceEditSubmit handles the webhook edit form submission
|
||||||
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source update logic
|
// TODO: Implement webhook update logic
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceDelete handles webhook source deletion
|
// HandleSourceDelete handles webhook deletion
|
||||||
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source deletion logic
|
// TODO: Implement webhook deletion logic
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceLogs shows the request/response logs for a webhook source
|
// HandleSourceLogs shows the request/response logs for a webhook
|
||||||
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: Implement source logs page
|
// TODO: Implement webhook logs page
|
||||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleWebhook handles incoming webhook requests
|
// HandleWebhook handles incoming webhook requests at entrypoint URLs
|
||||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get webhook UUID from URL
|
// Get entrypoint UUID from URL
|
||||||
webhookUUID := chi.URLParam(r, "uuid")
|
entrypointUUID := chi.URLParam(r, "uuid")
|
||||||
if webhookUUID == "" {
|
if entrypointUUID == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the incoming webhook request
|
// Log the incoming webhook request
|
||||||
h.log.Info("webhook request received",
|
h.log.Info("webhook request received",
|
||||||
"uuid", webhookUUID,
|
"entrypoint_uuid", entrypointUUID,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"user_agent", r.UserAgent(),
|
"user_agent", r.UserAgent(),
|
||||||
@@ -32,7 +32,7 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement webhook handling logic
|
// TODO: Implement webhook handling logic
|
||||||
// For now, return "unimplemented" for all webhook POST requests
|
// Look up entrypoint by UUID, find parent webhook, fan out to targets
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
_, err := w.Write([]byte("unimplemented"))
|
_, err := w.Write([]byte("unimplemented"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ type LoggerParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
params LoggerParams
|
levelVar *slog.LevelVar
|
||||||
|
params LoggerParams
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:revive // lc parameter is required by fx even if unused
|
// 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 := new(Logger)
|
||||||
l.params = params
|
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
|
// Determine if we're running in a terminal
|
||||||
tty := false
|
tty := false
|
||||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||||
tty = true
|
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
|
var handler slog.Handler
|
||||||
opts := &slog.HandlerOptions{
|
opts := &slog.HandlerOptions{
|
||||||
Level: slog.LevelInfo,
|
Level: l.levelVar,
|
||||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused
|
ReplaceAttr: replaceAttr,
|
||||||
// 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 {
|
if tty {
|
||||||
@@ -63,34 +70,7 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) EnableDebugLogging() {
|
func (l *Logger) EnableDebugLogging() {
|
||||||
// Recreate logger with debug level
|
l.levelVar.Set(slog.LevelDebug)
|
||||||
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.logger.Debug("debug logging enabled", "debug", true)
|
l.logger.Debug("debug logging enabled", "debug", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // MiddlewareParams is a standard fx naming convention
|
// nolint:revive // MiddlewareParams is a standard fx naming convention
|
||||||
@@ -24,17 +25,20 @@ type MiddlewareParams struct {
|
|||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
|
Session *session.Session
|
||||||
}
|
}
|
||||||
|
|
||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *MiddlewareParams
|
params *MiddlewareParams
|
||||||
|
session *session.Session
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
||||||
s := new(Middleware)
|
s := new(Middleware)
|
||||||
s.params = ¶ms
|
s.params = ¶ms
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
|
s.session = params.Session
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +122,27 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: implement proper authentication
|
sess, err := s.session.Get(r)
|
||||||
s.log.Debug("AUTH: before request")
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,23 +90,23 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Webhook source management routes (require authentication)
|
// Webhook management routes (require authentication)
|
||||||
s.router.Route("/sources", func(r chi.Router) {
|
s.router.Route("/sources", func(r chi.Router) {
|
||||||
// TODO: Add authentication middleware here
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceList()) // List all sources
|
r.Get("/", s.h.HandleSourceList()) // List all webhooks
|
||||||
r.Get("/new", s.h.HandleSourceCreate()) // Show create form
|
r.Get("/new", s.h.HandleSourceCreate()) // Show create form
|
||||||
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
|
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||||
// TODO: Add authentication middleware here
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceDetail()) // View source details
|
r.Get("/", s.h.HandleSourceDetail()) // View webhook details
|
||||||
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
|
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
|
||||||
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
|
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
|
||||||
r.Post("/delete", s.h.HandleSourceDelete()) // Delete source
|
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
|
||||||
r.Get("/logs", s.h.HandleSourceLogs()) // View source logs
|
r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
|
||||||
})
|
})
|
||||||
|
|
||||||
// Webhook endpoint - accepts all HTTP methods
|
// Entrypoint endpoint - accepts incoming webhook POST requests
|
||||||
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
|
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,6 @@ import (
|
|||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
"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
|
// ServerParams is a standard fx naming convention for dependency injection
|
||||||
|
|||||||
108
static/css/input.css
Normal file
108
static/css/input.css
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Source the templates */
|
||||||
|
@source "../../templates/**/*.html";
|
||||||
|
|
||||||
|
/* Material Design inspired theme customization */
|
||||||
|
@theme {
|
||||||
|
/* Primary colors */
|
||||||
|
--color-primary-50: #e3f2fd;
|
||||||
|
--color-primary-100: #bbdefb;
|
||||||
|
--color-primary-200: #90caf9;
|
||||||
|
--color-primary-300: #64b5f6;
|
||||||
|
--color-primary-400: #42a5f5;
|
||||||
|
--color-primary-500: #2196f3;
|
||||||
|
--color-primary-600: #1e88e5;
|
||||||
|
--color-primary-700: #1976d2;
|
||||||
|
--color-primary-800: #1565c0;
|
||||||
|
--color-primary-900: #0d47a1;
|
||||||
|
|
||||||
|
/* Error colors */
|
||||||
|
--color-error-50: #ffebee;
|
||||||
|
--color-error-500: #f44336;
|
||||||
|
--color-error-700: #d32f2f;
|
||||||
|
|
||||||
|
/* Success colors */
|
||||||
|
--color-success-50: #e8f5e9;
|
||||||
|
--color-success-500: #4caf50;
|
||||||
|
--color-success-700: #388e3c;
|
||||||
|
|
||||||
|
/* Warning colors */
|
||||||
|
--color-warning-50: #fff3e0;
|
||||||
|
--color-warning-500: #ff9800;
|
||||||
|
--color-warning-700: #f57c00;
|
||||||
|
|
||||||
|
/* Material Design elevation shadows */
|
||||||
|
--shadow-elevation-1: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
|
||||||
|
--shadow-elevation-2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||||
|
--shadow-elevation-3: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material Design component styles */
|
||||||
|
@layer components {
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:ring-primary-500 shadow-elevation-1 hover:shadow-elevation-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 active:bg-gray-100 focus:ring-primary-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-error-500 text-white hover:bg-error-700 active:bg-red-800 focus:ring-red-500 shadow-elevation-1 hover:shadow-elevation-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-elevated {
|
||||||
|
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden hover:shadow-elevation-2 transition-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
.input {
|
||||||
|
@apply w-full px-4 py-3 border border-gray-300 rounded-md text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
@apply mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge-success {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error-50 text-error-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-50 text-primary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App bar / Navigation */
|
||||||
|
.app-bar {
|
||||||
|
@apply bg-white shadow-elevation-1 px-6 py-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert / Message boxes */
|
||||||
|
.alert-error {
|
||||||
|
@apply p-4 rounded-md mb-4 bg-error-50 text-error-700 border border-error-500/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
@apply p-4 rounded-md mb-4 bg-success-50 text-success-700 border border-success-500/20;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,59 +1 @@
|
|||||||
/* Webhooker main stylesheet */
|
/* Webhooker custom styles — see input.css for Tailwind theme */
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom styles for Webhooker */
|
|
||||||
|
|
||||||
/* Navbar customization */
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card hover effects */
|
|
||||||
.card {
|
|
||||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background opacity utilities */
|
|
||||||
.bg-opacity-10 {
|
|
||||||
background-color: rgba(var(--bs-success-rgb), 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-primary.bg-opacity-10 {
|
|
||||||
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User dropdown styling */
|
|
||||||
.navbar .dropdown-toggle::after {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar .dropdown-menu {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer styling */
|
|
||||||
footer {
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 2rem 0;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.display-4 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
2
static/css/tailwind.css
Normal file
2
static/css/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
5
static/js/alpine.min.js
vendored
Normal file
5
static/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -4,15 +4,29 @@
|
|||||||
<head>
|
<head>
|
||||||
{{template "htmlheader" .}}
|
{{template "htmlheader" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
{{template "navbar" .}}
|
<div class="flex-grow">
|
||||||
|
{{template "navbar" .}}
|
||||||
<!-- Main content -->
|
{{block "content" .}}{{end}}
|
||||||
{{block "content" .}}{{end}}
|
</div>
|
||||||
|
{{template "footer" .}}
|
||||||
<script src="/s/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script defer src="/s/js/alpine.min.js"></script>
|
||||||
<script src="/s/js/app.js"></script>
|
<script src="/s/js/app.js"></script>
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "footer"}}
|
||||||
|
<footer class="bg-gray-100 border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] mt-8">
|
||||||
|
<div class="max-w-6xl mx-auto px-8 py-6">
|
||||||
|
<div class="text-center text-sm text-gray-500 font-mono font-light">
|
||||||
|
<a href="https://git.eeqj.de/sneak/webhooker" class="hover:text-gray-700">Webhooker</a>
|
||||||
|
<span class="mx-1">by</span>
|
||||||
|
<a href="https://sneak.berlin" class="hover:text-gray-700">@sneak</a>
|
||||||
|
<span class="mx-3">|</span>
|
||||||
|
<span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}Webhooker{{end}}</title>
|
<title>{{block "title" .}}Webhooker{{end}}</title>
|
||||||
<link href="/s/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/s/css/tailwind.css">
|
||||||
<link href="/s/css/style.css" rel="stylesheet">
|
<style>[x-cloak] { display: none !important; }</style>
|
||||||
{{block "head" .}}{{end}}
|
{{block "head" .}}{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -3,75 +3,65 @@
|
|||||||
{{define "title"}}Home - Webhooker{{end}}
|
{{define "title"}}Home - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="container mt-5">
|
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||||
<div class="row">
|
<div class="text-center mb-10">
|
||||||
<div class="col-lg-8 mx-auto">
|
<h1 class="text-4xl font-medium text-gray-900">Welcome to Webhooker</h1>
|
||||||
<div class="text-center mb-5">
|
<p class="mt-3 text-lg text-gray-500">A reliable webhook proxy service for event delivery</p>
|
||||||
<h1 class="display-4">Welcome to Webhooker</h1>
|
</div>
|
||||||
<p class="lead text-muted">A reliable webhook proxy service for event delivery</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<!-- Server Status Card -->
|
<!-- Server Status Card -->
|
||||||
<div class="col-md-6">
|
<div class="card-elevated p-6">
|
||||||
<div class="card h-100 shadow-sm">
|
<div class="flex items-center mb-4">
|
||||||
<div class="card-body">
|
<div class="rounded-full bg-success-50 p-3 mr-4">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<svg class="w-6 h-6 text-success-500" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
|
<path d="M1.333 2.667C1.333 1.194 4.318 0 8 0s6.667 1.194 6.667 2.667V4c0 1.473-2.985 2.667-6.667 2.667S1.333 5.473 1.333 4V2.667z"/>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-server text-success" viewBox="0 0 16 16">
|
<path d="M1.333 6.334v3C1.333 10.805 4.318 12 8 12s6.667-1.194 6.667-2.667V6.334a6.51 6.51 0 0 1-1.458.79C11.81 7.684 9.967 8 8 8c-1.966 0-3.809-.317-5.208-.876a6.508 6.508 0 0 1-1.458-.79z"/>
|
||||||
<path d="M1.333 2.667C1.333 1.194 4.318 0 8 0s6.667 1.194 6.667 2.667V4c0 1.473-2.985 2.667-6.667 2.667S1.333 5.473 1.333 4V2.667z"/>
|
<path d="M14.667 11.668a6.51 6.51 0 0 1-1.458.789c-1.4.56-3.242.876-5.21.876-1.966 0-3.809-.316-5.208-.876a6.51 6.51 0 0 1-1.458-.79v1.666C1.333 14.806 4.318 16 8 16s6.667-1.194 6.667-2.667v-1.665z"/>
|
||||||
<path d="M1.333 6.334v3C1.333 10.805 4.318 12 8 12s6.667-1.194 6.667-2.667V6.334a6.51 6.51 0 0 1-1.458.79C11.81 7.684 9.967 8 8 8c-1.966 0-3.809-.317-5.208-.876a6.508 6.508 0 0 1-1.458-.79z"/>
|
</svg>
|
||||||
<path d="M14.667 11.668a6.51 6.51 0 0 1-1.458.789c-1.4.56-3.242.876-5.21.876-1.966 0-3.809-.316-5.208-.876a6.51 6.51 0 0 1-1.458-.79v1.666C1.333 14.806 4.318 16 8 16s6.667-1.194 6.667-2.667v-1.665z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 class="card-title mb-1">Server Status</h5>
|
|
||||||
<p class="text-success mb-0">Online</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<small class="text-muted">Uptime</small>
|
|
||||||
<p class="h4 mb-0">{{.Uptime}}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small class="text-muted">Version</small>
|
|
||||||
<p class="mb-0"><code>{{.Version}}</code></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<!-- Users Card -->
|
<h2 class="text-lg font-medium text-gray-900">Server Status</h2>
|
||||||
<div class="col-md-6">
|
<span class="badge-success">Online</span>
|
||||||
<div class="card h-100 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<div class="rounded-circle bg-primary bg-opacity-10 p-3 me-3">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-people text-primary" viewBox="0 0 16 16">
|
|
||||||
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 class="card-title mb-1">Users</h5>
|
|
||||||
<p class="text-muted mb-0">Registered accounts</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="h2 mb-0">{{.UserCount}}</p>
|
|
||||||
<small class="text-muted">Total users</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
{{if not .User}}
|
<div>
|
||||||
<div class="text-center mt-5">
|
<p class="text-sm text-gray-500">Uptime</p>
|
||||||
<p class="text-muted">Ready to get started?</p>
|
<p class="text-2xl font-medium text-gray-900">{{.Uptime}}</p>
|
||||||
<a href="/pages/login" class="btn btn-primary">Login to your account</a>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Version</p>
|
||||||
|
<p class="font-mono text-sm text-gray-700">{{.Version}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Card -->
|
||||||
|
<div class="card-elevated p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="rounded-full bg-primary-50 p-3 mr-4">
|
||||||
|
<svg class="w-6 h-6 text-primary-500" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Users</h2>
|
||||||
|
<p class="text-sm text-gray-500">Registered accounts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-4xl font-medium text-gray-900">{{.UserCount}}</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Total users</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if not .User}}
|
||||||
|
<div class="text-center mt-10">
|
||||||
|
<p class="text-gray-500 mb-4">Ready to get started?</p>
|
||||||
|
<a href="/pages/login" class="btn-primary">Login to your account</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -2,85 +2,56 @@
|
|||||||
|
|
||||||
{{define "title"}}Login - Webhooker{{end}}
|
{{define "title"}}Login - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "head"}}
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
.login-container {
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 100px auto;
|
|
||||||
}
|
|
||||||
.login-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 20px rgba(0,0,0,0.1);
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
.login-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.login-header h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.login-header p {
|
|
||||||
color: #666;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
|
|
||||||
}
|
|
||||||
.btn-primary {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.error-message {
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="container">
|
<div class="min-h-screen flex items-center justify-center py-12 px-4">
|
||||||
<div class="login-container">
|
<div class="max-w-md w-full">
|
||||||
<div class="login-card">
|
<div class="text-center mb-8">
|
||||||
<div class="login-header">
|
<h1 class="text-3xl font-medium text-gray-900">Webhooker</h1>
|
||||||
<h1>Webhooker</h1>
|
<p class="mt-2 text-gray-600">Sign in to your account</p>
|
||||||
<p>Sign in to your account</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="card p-8">
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<div class="alert alert-danger error-message" role="alert">
|
<div class="alert-error">
|
||||||
{{.Error}}
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{.Error}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/pages/login">
|
<form method="POST" action="/pages/login" class="space-y-6">
|
||||||
<div class="mb-3">
|
<div class="form-group">
|
||||||
<label for="username" class="form-label">Username</label>
|
<label for="username" class="label">Username</label>
|
||||||
<input type="text" class="form-control" id="username" name="username"
|
<input
|
||||||
placeholder="Enter your username" required autofocus>
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="form-group">
|
||||||
<label for="password" class="form-label">Password</label>
|
<label for="password" class="label">Password</label>
|
||||||
<input type="password" class="form-control" id="password" name="password"
|
<input
|
||||||
placeholder="Enter your password" required>
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
<button type="submit" class="btn-primary w-full py-3">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-4 text-center text-muted">
|
|
||||||
<small>© 2025 Webhooker. All rights reserved.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,46 +1,50 @@
|
|||||||
{{define "navbar"}}
|
{{define "navbar"}}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
<nav class="app-bar" x-data="{ open: false }">
|
||||||
<div class="container-fluid">
|
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||||
<a class="navbar-brand" href="/">Webhooker</a>
|
<div class="flex items-center gap-3">
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||||
<span class="navbar-toggler-icon"></span>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto">
|
<!-- Desktop navigation -->
|
||||||
{{if .User}}
|
<div class="hidden md:flex items-center gap-4">
|
||||||
<li class="nav-item">
|
{{if .User}}
|
||||||
<a class="nav-link" href="/sources">Sources</a>
|
<a href="/sources" class="btn-text">Sources</a>
|
||||||
</li>
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
{{end}}
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
</ul>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
<ul class="navbar-nav">
|
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||||
{{if .User}}
|
</svg>
|
||||||
<!-- Logged in state -->
|
{{.User.Username}}
|
||||||
<li class="nav-item dropdown">
|
</a>
|
||||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-circle me-2" viewBox="0 0 16 16">
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
</form>
|
||||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
{{else}}
|
||||||
</svg>
|
<a href="/pages/login" class="btn-primary">Login</a>
|
||||||
{{.User.Username}}
|
{{end}}
|
||||||
</a>
|
</div>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
</div>
|
||||||
<li><a class="dropdown-item" href="/user/{{.User.Username}}">Profile</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
<!-- Mobile navigation -->
|
||||||
<li>
|
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
||||||
<form method="POST" action="/pages/logout" class="m-0">
|
<div class="flex flex-col gap-2">
|
||||||
<button type="submit" class="dropdown-item">Logout</button>
|
{{if .User}}
|
||||||
</form>
|
<a href="/sources" class="btn-text w-full text-left">Sources</a>
|
||||||
</li>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
</ul>
|
<form method="POST" action="/pages/logout">
|
||||||
</li>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
{{else}}
|
</form>
|
||||||
<!-- Logged out state -->
|
{{else}}
|
||||||
<li class="nav-item">
|
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||||
<a class="nav-link" href="/pages/login">Login</a>
|
{{end}}
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -3,51 +3,48 @@
|
|||||||
{{define "title"}}Profile - Webhooker{{end}}
|
{{define "title"}}Profile - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="container mt-5">
|
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||||
<div class="row">
|
<h1 class="text-2xl font-medium text-gray-900 mb-6">User Profile</h1>
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
<h1 class="mb-4">User Profile</h1>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card p-6">
|
||||||
<div class="card-body">
|
<div class="flex items-center mb-6">
|
||||||
<div class="row align-items-center mb-3">
|
<div class="mr-4">
|
||||||
<div class="col-auto">
|
<svg class="w-16 h-16 text-primary-500" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-person-circle text-primary" viewBox="0 0 16 16">
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
</svg>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<h3 class="mb-0">{{.User.Username}}</h3>
|
|
||||||
<p class="text-muted mb-0">User ID: {{.User.ID}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Account Information</h5>
|
|
||||||
<dl class="row">
|
|
||||||
<dt class="col-sm-4">Username</dt>
|
|
||||||
<dd class="col-sm-8">{{.User.Username}}</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Account Type</dt>
|
|
||||||
<dd class="col-sm-8">Standard User</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Settings</h5>
|
|
||||||
<p class="text-muted">Profile settings and preferences will be available here.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-medium text-gray-900">{{.User.Username}}</h2>
|
||||||
|
<p class="text-sm text-gray-500">User ID: {{.User.ID}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<hr class="border-gray-200 mb-6">
|
||||||
<a href="/" class="btn btn-secondary">Back to Home</a>
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-3">Account Information</h3>
|
||||||
|
<dl class="space-y-3">
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
||||||
|
<dd class="text-sm text-gray-900">Standard User</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-3">Settings</h3>
|
||||||
|
<p class="text-sm text-gray-500">Profile settings and preferences will be available here.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/" class="btn-secondary">Back to Home</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
8
templates/templates.go
Normal file
8
templates/templates.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.html
|
||||||
|
var Templates embed.FS
|
||||||
Reference in New Issue
Block a user