Compare commits

...

9 Commits

Author SHA1 Message Date
clawbot
853f25ee67 chore: add MIT LICENSE
All checks were successful
check / check (push) Successful in 57s
Add MIT license file with copyright holder Jeffrey Paul <sneak@sneak.berlin>.
2026-03-01 16:01:44 -08:00
clawbot
7d13c9da17 feat: add auth middleware for protected routes
Add RequireAuth middleware that checks for a valid session and
redirects unauthenticated users to /pages/login. Applied to all
/sources and /source/{sourceID} routes. The middleware uses the
existing session package for authentication checks.

closes #9
2026-03-01 16:01:44 -08:00
clawbot
e6b79ce1be fix: remove redundant godotenv import
The godotenv/autoload import was duplicated in both config.go and
server.go. Keep it only in config.go where configuration is loaded.

closes #11
2026-03-01 16:01:44 -08:00
clawbot
483d7f31ff refactor: simplify config to prefer env vars
Configuration now prefers environment variables over config.yaml values.
Each config field has a corresponding env var (DBURL, PORT, DEBUG, etc.)
that takes precedence when set. The config.yaml fallback is preserved
for development convenience.

closes #10
2026-03-01 16:01:44 -08:00
clawbot
3e3d44a168 refactor: use slog.LevelVar for dynamic log levels
Replace the pattern of recreating the logger handler when enabling debug
logging. Now use slog.LevelVar which allows changing the log level
dynamically without recreating the handler or logger instance.

closes #8
2026-03-01 16:01:44 -08:00
clawbot
d4eef6bd6a refactor: use go:embed for templates
Templates are now embedded using //go:embed and parsed once at startup
with template.Must(template.ParseFS(...)). This avoids re-parsing
template files from disk on every request and removes the dependency
on template files being present at runtime.

closes #7
2026-03-01 16:01:44 -08:00
clawbot
7bbe47b943 refactor: rename Processor to Webhook and Webhook to Entrypoint
The top-level entity that groups entrypoints and targets is now called
Webhook (was Processor). The inbound URL endpoint entity is now called
Entrypoint (was Webhook). This rename affects database models, handler
comments, routes, and README documentation.

closes #12
2026-03-01 16:01:44 -08:00
b5cf4c3d2f docs: comprehensive README rewrite with complete service specification (#13)
All checks were successful
check / check (push) Successful in 4s
## Summary

Rewrites README.md from a basic scaffold into a comprehensive service description and specification that documents the entire webhooker application.

closes #3

## What Changed

### Naming Scheme

Proposes a clear naming scheme for the data model entities:
- **Processor → Webhook**: The top-level configuration entity that groups entrypoints and targets
- **Webhook → Entrypoint**: The receiver URL (`/hooks/<uuid>`) where external services POST events
- **Target**: Unchanged — delivery destinations for events

This is documented as the target architecture in the README. The actual code rename is tracked in [issue #12](#12).

### Data Model Documentation

Documents all 8 entities with:
- Complete field tables (name, type, description) for every entity
- Relationship descriptions (belongs-to, has-many)
- Enum values for TargetType and DeliveryStatus
- Entity relationship diagram (ASCII)
- Common fields from BaseModel

### Database Architecture

Documents the separate database architecture:
- Main application DB: users, webhook configs, entrypoints, targets, API keys
- Per-webhook event DBs: events, deliveries, delivery results
- Rationale for separation (isolation, lifecycle, clean deletion, per-webhook retention, performance)

### Other Sections

- Complete API endpoint tables (current + planned)
- Package layout with file descriptions
- Request flow diagram
- Middleware stack documentation
- Authentication design (web sessions + planned API keys)
- Security measures
- Rate limiting design
- Dependency injection order
- Docker build pipeline description
- Phased TODO roadmap with links to filed issues
- License set to MIT

### Code Style Divergence Issues Filed

As part of reviewing the code against sneak/prompts standards:
- [#7](#7) — Templates should use go:embed
- [#8](#8) — Logger should use slog.LevelVar
- [#9](#9) — Source management routes lack auth middleware
- [#10](#10) — Config should prefer environment variables
- [#11](#11) — Redundant godotenv/autoload import
- [#12](#12) — Rename Processor → Webhook, Webhook → Entrypoint

## Verification

- `make fmt` —  passes
- `docker build .` —  passes (README-only change, no code modifications)

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #13
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 00:43:55 +01:00
011ec270c2 Replace Bootstrap with Tailwind CSS + Alpine.js (#14)
Some checks failed
check / check (push) Has been cancelled
## Summary

Replaces Bootstrap CSS/JS framework with Tailwind CSS v4 + Alpine.js, matching the µPaaS UI pattern.

## Changes

- **Removed Bootstrap** — all Bootstrap CSS/JS references removed from templates
- **Added Tailwind CSS v4** — `static/css/input.css` with Material Design inspired theme, compiled to `static/css/tailwind.css`
- **Added Alpine.js 3.14.9** — vendored as `static/js/alpine.min.js` for reactive UI components
- **Rewrote all templates** to use Tailwind utility classes:
  - `base.html` — new layout structure with footer, matches µPaaS pattern
  - `htmlheader.html` — Tailwind CSS link, `[x-cloak]` style
  - `navbar.html` — Alpine.js mobile menu toggle, responsive design
  - `index.html` — card-based dashboard with Tailwind classes
  - `login.html` — centered login form with Material Design styling
  - `profile.html` — clean profile layout
- **Added `make css` target** — compiles Tailwind CSS using standalone CLI
- **Component classes** in `input.css` — reusable `.btn-primary`, `.card`, `.input`, `.alert-error` etc.

## Testing

- `make fmt` 
- `make check` (fmt-check, lint, test, build) 
- `docker build .` 

closes #4

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #14
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 00:42:29 +01:00
34 changed files with 1244 additions and 598 deletions

21
LICENSE Normal file
View 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.

View File

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

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

View File

@@ -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: &params, params: &params,
} }
// 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)

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

View File

@@ -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"`
} }

View File

@@ -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"`
}

View File

@@ -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"`
} }

View File

@@ -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"`
} }

View File

@@ -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"`
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = &params s.params = &params
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)
}) })
} }

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

5
static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1,2 @@
// Webhooker client-side JavaScript // Webhooker client-side JavaScript
console.log("Webhooker loaded"); console.log("Webhooker loaded");

View File

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

View File

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

View File

@@ -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="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Server Status Card -->
<div class="row g-4"> <div class="card-elevated p-6">
<!-- Server Status Card --> <div class="flex items-center mb-4">
<div class="col-md-6"> <div class="rounded-full bg-success-50 p-3 mr-4">
<div class="card h-100 shadow-sm"> <svg class="w-6 h-6 text-success-500" fill="currentColor" viewBox="0 0 16 16">
<div class="card-body"> <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"/>
<div class="d-flex align-items-center mb-3"> <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"/>
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3"> <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 xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-server text-success" viewBox="0 0 16 16"> </svg>
<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> </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}}

View File

@@ -2,86 +2,57 @@
{{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"
</div> id="username"
name="username"
<div class="mb-4"> required
<label for="password" class="form-label">Password</label> autofocus
<input type="password" class="form-control" id="password" name="password" autocomplete="username"
placeholder="Enter your password" required> placeholder="Enter your username"
class="input"
>
</div> </div>
<button type="submit" class="btn btn-primary">Sign In</button> <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-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>
{{end}} {{end}}

View File

@@ -1,47 +1,51 @@
{{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>
{{end}} {{end}}

View File

@@ -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 p-6">
<div class="flex items-center mb-6">
<div class="card shadow-sm"> <div class="mr-4">
<div class="card-body"> <svg class="w-16 h-16 text-primary-500" fill="currentColor" viewBox="0 0 16 16">
<div class="row align-items-center mb-3"> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<div class="col-auto"> <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 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"> </svg>
<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> </div>
<div>
<div class="mt-4"> <h2 class="text-xl font-medium text-gray-900">{{.User.Username}}</h2>
<a href="/" class="btn btn-secondary">Back to Home</a> <p class="text-sm text-gray-500">User ID: {{.User.ID}}</p>
</div>
</div>
<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>
</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
View File

@@ -0,0 +1,8 @@
package templates
import (
"embed"
)
//go:embed *.html
var Templates embed.FS