Compare commits
9 Commits
b437955378
...
853f25ee67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853f25ee67 | ||
|
|
7d13c9da17 | ||
|
|
e6b79ce1be | ||
|
|
483d7f31ff | ||
|
|
3e3d44a168 | ||
|
|
d4eef6bd6a | ||
|
|
7bbe47b943 | ||
| b5cf4c3d2f | |||
| 011ec270c2 |
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_GOAL := check
|
||||
@@ -41,3 +41,6 @@ hooks:
|
||||
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
|
||||
@chmod +x .git/hooks/pre-commit
|
||||
@echo "pre-commit hook installed"
|
||||
|
||||
css:
|
||||
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
798
README.md
798
README.md
@@ -1,187 +1,737 @@
|
||||
# webhooker
|
||||
|
||||
webhooker is a Go web application by [@sneak](https://sneak.berlin) that
|
||||
receives, stores, and proxies webhooks to configured targets with retry
|
||||
support, observability, and a management web UI. License: MIT.
|
||||
webhooker is a self-hosted webhook proxy and store-and-forward service
|
||||
written in [Go](https://golang.org) by
|
||||
[@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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24+
|
||||
- golangci-lint v1.64+
|
||||
- Docker (for containerized deployment)
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||
cd webhooker
|
||||
|
||||
# Install dependencies
|
||||
# Install Go dependencies
|
||||
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)
|
||||
make check
|
||||
|
||||
# Run in development mode (uses SQLite in current directory)
|
||||
make dev
|
||||
|
||||
# Build Docker image
|
||||
make docker
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
### Development Commands
|
||||
|
||||
- `WEBHOOKER_ENVIRONMENT` — `dev` or `prod` (default: `dev`)
|
||||
- `DEBUG` — Enable debug logging
|
||||
- `PORT` — Server port (default: `8080`)
|
||||
- `DBURL` — Database connection string
|
||||
- `SESSION_KEY` — Base64-encoded 32-byte session key (required in prod)
|
||||
- `METRICS_USERNAME` — Username for metrics endpoint
|
||||
- `METRICS_PASSWORD` — Password for metrics endpoint
|
||||
- `SENTRY_DSN` — Sentry error reporting (optional)
|
||||
```bash
|
||||
make fmt # Format code (gofmt + goimports)
|
||||
make lint # Run golangci-lint
|
||||
make test # Run tests with race detection
|
||||
make check # fmt-check + lint + test + build (CI gate)
|
||||
make build # Build binary to bin/webhooker
|
||||
make dev # go run ./cmd/webhooker
|
||||
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
|
||||
|
||||
Webhook integrations between services are fragile: the receiving service
|
||||
must be up when the webhook fires, there is no built-in retry for most
|
||||
webhook senders, and there is no visibility into what was sent or when.
|
||||
webhooker solves this by acting as a reliable intermediary that receives
|
||||
webhooks, stores them, and delivers them to configured targets — with
|
||||
optional retries, logging, and Prometheus metrics for observability.
|
||||
Webhook integrations between services are inherently fragile. The
|
||||
receiving service must be online when the webhook fires, most webhook
|
||||
senders provide no built-in retry mechanism, and there is no standard
|
||||
way to inspect what was sent, when it was sent, or whether delivery
|
||||
succeeded.
|
||||
|
||||
Use cases include:
|
||||
webhooker solves this by acting as a durable intermediary:
|
||||
|
||||
- Store-and-forward with unlimited retries for unreliable receivers
|
||||
- Prometheus/Grafana metric analysis of webhook frequency, size, and
|
||||
handler performance
|
||||
- Introspection and debugging of webhook payloads
|
||||
- Redelivery of webhook events for application testing
|
||||
- Fan-out delivery of webhooks to multiple targets
|
||||
- HA ingestion endpoint for delivery to less reliable systems
|
||||
1. **Reliable ingestion** — webhooker is always ready to accept incoming
|
||||
webhooks. It stores every received event before attempting any
|
||||
delivery, so nothing is lost if downstream targets are unavailable.
|
||||
|
||||
2. **Guaranteed delivery** — Events are queued for delivery to each
|
||||
configured target. Failed deliveries are retried with configurable
|
||||
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
|
||||
|
||||
### Architecture
|
||||
### Architecture Overview
|
||||
|
||||
webhooker uses Uber's fx dependency injection library for managing
|
||||
application lifecycle. It uses `log/slog` for structured logging, GORM
|
||||
for database access, and SQLite (via `modernc.org/sqlite`, pure Go, no
|
||||
CGO) for storage. HTTP routing uses chi.
|
||||
webhooker is structured as a standard Go HTTP server following the
|
||||
[sneak/prompts GO_HTTP_SERVER_CONVENTIONS](https://git.eeqj.de/sneak/prompts/src/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md).
|
||||
It uses:
|
||||
|
||||
### 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
|
||||
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.
|
||||
### Naming Conventions
|
||||
|
||||
Instead, each webhook endpoint 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 on a per-webhook basis in the application
|
||||
when needed (e.g. to protect against a misbehaving sender).
|
||||
This README uses the target naming scheme for the application's core
|
||||
entities. The current codebase uses older names that will be updated in
|
||||
a future refactor (see
|
||||
[issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)):
|
||||
|
||||
### 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
|
||||
monolithic database:
|
||||
|
||||
- **Main application database** — Stores application configuration and
|
||||
all standard webapp data: users, sessions, API keys, and global
|
||||
settings.
|
||||
- **Per-webhook databases** — Each webhook gets its own dedicated SQLite
|
||||
database file containing: input logs, webhook logs, and all output
|
||||
queues for that specific webhook.
|
||||
|
||||
This separation provides several benefits: webhook databases can be
|
||||
independently backed up, rotated, or archived; a high-volume webhook
|
||||
won't cause lock contention or bloat affecting the main application; and
|
||||
individual webhook data can be cleanly deleted when a webhook 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
|
||||
Throughout this document, the target names are used. The code rename is
|
||||
tracked separately.
|
||||
|
||||
### Data Model
|
||||
|
||||
- **Users** — Service users with username/password (Argon2id hashing)
|
||||
- **Webhooks** — Webhook processing units, many-to-one with users
|
||||
- **Entrypoints** — Inbound URL endpoints feeding into webhooks
|
||||
- **Targets** — Delivery destinations per webhook (HTTP, retry, database, log)
|
||||
- **Events** — Captured webhook payloads
|
||||
- **Deliveries** — Pairing of events with targets
|
||||
- **Delivery Results** — Outcome of each delivery attempt
|
||||
- **API Keys** — Programmatic access credentials per user
|
||||
webhooker's data model has eight entities organized into two tiers: the
|
||||
**application tier** (user and webhook configuration) and the **event
|
||||
tier** (event ingestion, delivery, and logging).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION TIER │
|
||||
│ (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
|
||||
|
||||
- `GET /` — Web UI index page
|
||||
- `GET /.well-known/healthcheck` — Health check with uptime, version
|
||||
- `GET /s/*` — Static file serving (CSS, JS)
|
||||
- `GET /metrics` — Prometheus metrics (requires basic auth)
|
||||
- `POST /webhook/{uuid}` — Webhook receiver endpoint
|
||||
- `/pages/login`, `/pages/logout` — Authentication
|
||||
- `/user/{username}` — User profile
|
||||
- `/sources/*` — Webhook source management
|
||||
#### Public Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------------- | ----------- |
|
||||
| `GET` | `/` | Web UI index page (server-rendered) |
|
||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: status, uptime, version) |
|
||||
| `GET` | `/s/*` | Static file serving (embedded CSS, JS) |
|
||||
| `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
|
||||
|
||||
### Phase 1: Security & Infrastructure
|
||||
- [ ] Security headers (HSTS, CSP, X-Frame-Options)
|
||||
- [ ] Rate limiting middleware
|
||||
- [ ] CSRF protection for forms
|
||||
- [ ] Request ID tracking through entire lifecycle
|
||||
|
||||
### Phase 2: Authentication & Authorization
|
||||
- [ ] Authentication middleware for protected routes
|
||||
- [ ] 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)
|
||||
### Phase 1: Core Webhook Engine
|
||||
- [ ] Implement webhook reception and event storage at `/webhook/{uuid}`
|
||||
- [ ] Build event processing and target delivery engine
|
||||
- [ ] Implement HTTP target type (fire-and-forget POST)
|
||||
- [ ] Implement retry target type (exponential backoff)
|
||||
- [ ] Implement database target type (store only)
|
||||
- [ ] Implement log target type (console output)
|
||||
- [ ] Per-webhook rate limiting in the receiver handler
|
||||
- [ ] Webhook signature verification (GitHub, Stripe formats)
|
||||
|
||||
### Phase 2: Database Separation
|
||||
- [ ] Split into main application DB + per-webhook event DBs
|
||||
- [ ] Automatic event retention cleanup based on `retention_days`
|
||||
- [ ] Per-webhook database lifecycle management (create on webhook
|
||||
creation, delete on webhook removal)
|
||||
|
||||
### Phase 3: Security & Infrastructure
|
||||
- [ ] Implement authentication middleware for protected routes
|
||||
([#9](https://git.eeqj.de/sneak/webhooker/issues/9))
|
||||
- [ ] Security headers (HSTS, CSP, X-Frame-Options)
|
||||
- [ ] CSRF protection for forms
|
||||
- [ ] Session expiration and "remember me"
|
||||
- [ ] Password change/reset flow
|
||||
- [ ] API key authentication for programmatic access
|
||||
|
||||
### Phase 4: Web UI
|
||||
- [ ] Webhook source management pages (list, create, edit, delete)
|
||||
- [ ] Webhook management pages (list, create, edit, delete)
|
||||
- [ ] Webhook request log viewer with filtering
|
||||
- [ ] Delivery status and retry management UI
|
||||
- [ ] Manual event redelivery
|
||||
- [ ] Analytics dashboard (success rates, response times)
|
||||
- [ ] Replace Bootstrap with Tailwind CSS + Alpine.js
|
||||
([#4](https://git.eeqj.de/sneak/webhooker/issues/4))
|
||||
|
||||
### Phase 5: API
|
||||
### Phase 5: REST API
|
||||
- [ ] RESTful CRUD for webhooks, entrypoints, targets
|
||||
- [ ] 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
|
||||
- [ ] Email source and delivery target types
|
||||
- [ ] Email delivery target type
|
||||
- [ ] 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
|
||||
- [ ] Webhook event search and filtering
|
||||
- [ ] Multi-user with role-based access
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE) for details.
|
||||
MIT
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
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 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
/* Webhooker custom styles — see input.css for Tailwind theme */
|
||||
|
||||
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>
|
||||
{{template "htmlheader" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
|
||||
<!-- Main content -->
|
||||
{{block "content" .}}{{end}}
|
||||
|
||||
<script src="/s/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<div class="flex-grow">
|
||||
{{template "navbar" .}}
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
<script defer src="/s/js/alpine.min.js"></script>
|
||||
<script src="/s/js/app.js"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}Webhooker{{end}}</title>
|
||||
<link href="/s/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/s/css/style.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/s/css/tailwind.css">
|
||||
<style>[x-cloak] { display: none !important; }</style>
|
||||
{{block "head" .}}{{end}}
|
||||
{{end}}
|
||||
@@ -3,75 +3,65 @@
|
||||
{{define "title"}}Home - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-4">Welcome to Webhooker</h1>
|
||||
<p class="lead text-muted">A reliable webhook proxy service for event delivery</p>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-4xl font-medium text-gray-900">Welcome to Webhooker</h1>
|
||||
<p class="mt-3 text-lg text-gray-500">A reliable webhook proxy service for event delivery</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Server Status Card -->
|
||||
<div class="col-md-6">
|
||||
<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-success bg-opacity-10 p-3 me-3">
|
||||
<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 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="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="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 class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Server Status Card -->
|
||||
<div class="card-elevated p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="rounded-full bg-success-50 p-3 mr-4">
|
||||
<svg class="w-6 h-6 text-success-500" fill="currentColor" viewBox="0 0 16 16">
|
||||
<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="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="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>
|
||||
|
||||
<!-- Users Card -->
|
||||
<div class="col-md-6">
|
||||
<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>
|
||||
<h2 class="text-lg font-medium text-gray-900">Server Status</h2>
|
||||
<span class="badge-success">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .User}}
|
||||
<div class="text-center mt-5">
|
||||
<p class="text-muted">Ready to get started?</p>
|
||||
<a href="/pages/login" class="btn btn-primary">Login to your account</a>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Uptime</p>
|
||||
<p class="text-2xl font-medium text-gray-900">{{.Uptime}}</p>
|
||||
</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>
|
||||
{{end}}
|
||||
</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>
|
||||
{{end}}
|
||||
@@ -2,85 +2,56 @@
|
||||
|
||||
{{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"}}
|
||||
<div class="container">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h1>Webhooker</h1>
|
||||
<p>Sign in to your account</p>
|
||||
</div>
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-medium text-gray-900">Webhooker</h1>
|
||||
<p class="mt-2 text-gray-600">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-8">
|
||||
{{if .Error}}
|
||||
<div class="alert alert-danger error-message" role="alert">
|
||||
{{.Error}}
|
||||
<div class="alert-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>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/pages/login">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
placeholder="Enter your username" required autofocus>
|
||||
<form method="POST" action="/pages/login" class="space-y-6">
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="username"
|
||||
placeholder="Enter your username"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
placeholder="Enter your password" required>
|
||||
<div class="form-group">
|
||||
<label for="password" class="label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="Enter your password"
|
||||
class="input"
|
||||
>
|
||||
</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>
|
||||
|
||||
<div class="mt-4 text-center text-muted">
|
||||
<small>© 2025 Webhooker. All rights reserved.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,46 +1,50 @@
|
||||
{{define "navbar"}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">Webhooker</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
<nav class="app-bar" x-data="{ open: false }">
|
||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||
</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>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
{{if .User}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/sources">Sources</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
{{if .User}}
|
||||
<!-- Logged in state -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<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">
|
||||
<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"/>
|
||||
</svg>
|
||||
{{.User.Username}}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="/user/{{.User.Username}}">Profile</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form method="POST" action="/pages/logout" class="m-0">
|
||||
<button type="submit" class="dropdown-item">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{{else}}
|
||||
<!-- Logged out state -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/pages/login">Login</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text">Sources</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||
<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"/>
|
||||
</svg>
|
||||
{{.User.Username}}
|
||||
</a>
|
||||
<form method="POST" action="/pages/logout" class="inline">
|
||||
<button type="submit" class="btn-text">Logout</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary">Login</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile navigation -->
|
||||
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex flex-col gap-2">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text w-full text-left">Sources</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||
<form method="POST" action="/pages/logout">
|
||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -3,51 +3,48 @@
|
||||
{{define "title"}}Profile - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<h1 class="mb-4">User Profile</h1>
|
||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||
<h1 class="text-2xl font-medium text-gray-900 mb-6">User Profile</h1>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center mb-3">
|
||||
<div class="col-auto">
|
||||
<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 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>
|
||||
</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 class="card p-6">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="mr-4">
|
||||
<svg class="w-16 h-16 text-primary-500" fill="currentColor" viewBox="0 0 16 16">
|
||||
<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"/>
|
||||
</svg>
|
||||
</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">
|
||||
<a href="/" class="btn btn-secondary">Back to Home</a>
|
||||
<hr class="border-gray-200 mb-6">
|
||||
|
||||
<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 class="mt-6">
|
||||
<a href="/" class="btn-secondary">Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user