Initial commit

This commit is contained in:
Kaki Filem Team 2026-01-31 20:35:12 +08:00
commit f3d5de9f07
16 changed files with 2918 additions and 0 deletions

110
.gitignore vendored Normal file
View File

@ -0,0 +1,110 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Environment variables
.env.*
!.env.example
# Database files
*.db
*.sqlite
*.sqlite3
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
logs/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Railway specific
.railway/
# Assets (design files)
*.psd
*.xcf
*.ai
*.sketch
# Archives
*.zip
*.tar.gz
*.7z
*.rar
# Temporary files
*.bak
*.tmp
*.temp
*~
# Node modules (if you add any frontend tooling)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production builds
/dist
/build

111
CONTRIBUTING Normal file
View File

@ -0,0 +1,111 @@
# Contributing to Webhook Catcher
Thanks for your interest in contributing to **Webhook Catcher** 🎉
All kinds of contributions are welcome — bug reports, documentation improvements, and code changes.
---
## 🧭 Getting Started
1. Fork the repository
2. Clone your fork locally
3. Create a new branch from `main`:
```bash
git checkout -b feature/my-change
```
---
## 🛠 Development Setup
### Requirements
- Python 3.13+ // Changed from 3.9+
- pip
- (Optional) Docker
### Local setup
```bash
pip install -r requirements.txt
uvicorn main:app --reload
```
The app will be available at:
```
http://localhost:8000
```
---
## 🧪 Testing Changes
Before opening a PR, please ensure:
- The app starts without errors
- `/healthz` returns `200 OK`
- Webhooks can be received via `/webhook`
- UI pages load correctly
For Railway-related changes, please test:
- Deployment via the Railway template (volume auto-attached)
- Application behavior across redeploys (data persistence)
---
## 🧩 Database & Volumes (Important)
Webhook Catcher uses SQLite.
- The database path is `/app/data/webhooks.db`
- When deployed via the **Railway template**, a persistent volume is
**automatically attached and mounted** at `/app/data`
- Data persists across redeploys without manual setup
For local development:
- Data is stored on the local filesystem
- Data loss on container rebuilds is expected unless you mount a volume manually
Please do not introduce hardcoded paths outside `/app/data`.
---
## 📦 Pull Request Guidelines
When opening a PR:
- Keep changes focused and small
- Explain **why** the change is needed
- Update documentation if behavior changes
- Reference related issues when applicable
### Good PR titles
- `Fix: handle empty webhook payloads`
- `Docs: clarify Railway volume setup`
- `Feature: protect UI with optional password`
---
## 🧹 Code Style
- Follow existing code patterns
- Prefer readability over cleverness
- Avoid introducing new dependencies unless necessary
---
## 🐞 Reporting Issues
If you find a bug:
- Include steps to reproduce
- Include logs or screenshots if relevant
- Mention your platform (local, Railway, Docker)
---
## 🤝 Code of Conduct
Be respectful and constructive.
Harassment or toxic behavior will not be tolerated.
---
Thanks again for contributing — every improvement helps 🚀

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app/ .
EXPOSE $PORT
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080}"]

21
MIT License.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Aman
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.

253
README.md Normal file
View File

@ -0,0 +1,253 @@
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.13-blue?style=flat)
![Framework](https://img.shields.io/badge/FastAPI-async-green)
![Database](https://img.shields.io/badge/SQLite-persistent-lightgrey)
![UI](https://img.shields.io/badge/HTMX-realtime-orange)
![Deploy](https://img.shields.io/badge/deploy-Railway-purple)
# Webhook Catcher
> The easiest way to capture and debug webhooks in production and development.
A developer-friendly webhook debugging tool — ideal for testing GitHub/Stripe events, bot integrations, or any webhook-based workflow.
Replay, filter, and forward webhooks in real time with zero config.
---
## Features
- Capture and view incoming webhooks in real time
- Search, filter, and export webhook logs
- Replay webhooks to any target URL
- Multi-service ready: forward webhooks to your own bot/service
- **Production-ready admin protection** for sensitive operations
- Deploy instantly on Railway or any platform
---
## 🚀 Deploy on Railway (Recommended)
⚡ **Why Railway?**
Webhook Catcher is optimized for Railway:
- 🧠 Zero config — deploy in seconds
- 🪄 Auto-generated domain (instantly test your webhook)
- 💾 Automatic persistent storage (no data loss on redeploy)
- 🔐 Built-in secret management & protection
---
## 💾 Persistent Storage (Railway)
Webhook Catcher uses SQLite for storage.
When deploying via the **Railway template**, a persistent volume is
**automatically created and mounted** — no manual setup required.
✔ Data persists across redeploys
✔ No configuration needed
✔ Works out of the box
---
## ✨ Sophisticated Architecture Overview
```mermaid
graph TB
A[External Webhooks<br/>GitHub, Stripe, IoT] --> B[Load Balancer]
B --> C[Webhook Catcher Service<br/>FastAPI + HTMX]
C --> D[SQLite Database<br/>Persistent Volume]
C --> E[Your Bot Service<br/>Python/Node/etc]
E --> F[Discord/Slack/etc<br/>Notifications]
C --> G[Real-time Web UI<br/>Live Updates]
style C fill:#0ea5e9
style E fill:#10b981
style D fill:#f59e0b
```
---
## 🧩 Use Cases
- Debug Stripe or GitHub webhooks in production
- Build and test bots for Discord, Telegram, or Slack
- Capture IoT webhook payloads for later processing
- Replay webhooks to local dev or staging environments
---
## Quick Start
1. **Deploy on Railway:**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/webhook-catcher?referralCode=nIQTyp&utm_medium=integration&utm_source=template&utm_campaign=generic)
2. **Send a webhook:**
Use your deployed `/webhook` endpoint with any service or tool (e.g., GitHub, Stripe, curl).
```bash
curl -X POST https://your-app.railway.app/webhook \
-H "Content-Type: application/json" \
-d '{"event": "test", "message": "Hello"}'
```
3. **View and replay webhooks:**
Open the web UI to see logs, search, export, or replay to another URL.
---
## Screenshots
![Main UI](assets/main.png)
![Webhook Logs](assets/logs.png)
---
## 🔧 Configuration
### Environment Variables
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| `FORWARD_WEBHOOK_URL` | Forward incoming webhooks to another service | No | - |
| `FORWARD_WEBHOOK_TOKEN` | Authentication token for secure forwarding | No | - |
| `ADMIN_TOKEN` | **Admin protection token for sensitive operations** | No | - |
| `FRONTEND_PASSWORD` | **Password to protect the web UI from public access** | No | - |
### 🔒 Admin Protection (Production Feature)
The `ADMIN_TOKEN` environment variable provides **production-grade security** for sensitive operations:
#### Protected Operations:
- **Clear all logs** (`POST /clear`)
- **Replay webhooks** (`POST /replay/{id}`)
#### Security Modes:
**1. No Protection (Development)**
```bash
# Leave ADMIN_TOKEN empty or unset
ADMIN_TOKEN=""
```
- All operations are publicly accessible
- Perfect for development and testing
**2. Token Protection (Production)**
```bash
# Set a strong admin token
ADMIN_TOKEN="your-secret-admin-token-123"
```
- Admin operations require authentication
- Token can be provided via:
- Header: `X-Admin-Token: your-secret-admin-token-123`
- Query parameter: `?admin_token=your-secret-admin-token-123`
#### Usage Examples:
**Clear logs with admin token:**
```bash
# Via header (recommended)
curl -X POST "https://your-app.railway.app/clear" \
-H "X-Admin-Token: your-secret-admin-token-123"
# Via query parameter
curl -X POST "https://your-app.railway.app/clear?admin_token=your-secret-admin-token-123"
```
**Replay webhook with admin token:**
```bash
curl -X POST "https://your-app.railway.app/replay/1?target_url=https://httpbin.org/post" \
-H "X-Admin-Token: your-secret-admin-token-123"
```
**Health check shows protection status:**
```bash
curl https://your-app.railway.app/healthz
# Returns: {"status": "ok", "admin_protected": true}
```
#### Security Best Practices:
- Use a strong, random token (minimum 20 characters)
- Keep the token secret and rotate it regularly
- Use headers instead of query parameters when possible
- Enable admin protection for production deployments
### 🔐 Frontend Password Protection
The `FRONTEND_PASSWORD` environment variable protects the **web UI** from public access using HTTP Basic Authentication.
#### Protected Routes:
- **Home page** (`GET /`)
- **Logs view** (`GET /logs`, `GET /logs/view`)
- **Export logs** (`GET /export`)
- **List webhooks** (`GET /webhooks`)
#### Unprotected Routes (remain open):
- **Webhook receiver** (`POST /webhook`) - external services must be able to send webhooks
- **Health check** (`GET /healthz`)
- **Config status** (`GET /config`)
#### Usage:
**1. No Protection (Development)**
```bash
# Leave FRONTEND_PASSWORD empty or unset
FRONTEND_PASSWORD=""
```
- Web UI is publicly accessible
- Perfect for development and testing
**2. Password Protection (Production)**
```bash
# Set a password to protect the web UI
FRONTEND_PASSWORD="your-secret-password"
```
- Accessing the web UI prompts for HTTP Basic Auth
- Username can be anything (only password is checked)
- Browser remembers credentials for the session
#### How it works:
When `FRONTEND_PASSWORD` is set, accessing protected routes will prompt your browser's built-in HTTP Basic Auth dialog. Enter any username and the configured password to access the UI.
---
## Advanced Features
### Multi-Service Architecture
- **Internal networking**: Link your own service using `FORWARD_WEBHOOK_URL`
- **Secure forwarding**: Use `FORWARD_WEBHOOK_TOKEN` for authenticated service-to-service communication
- **Real-time processing**: Forward webhooks immediately while storing for replay
### Data Persistence
- **SQLite database** with volume mounting
- **Export capabilities**: JSON and CSV formats
- **Search functionality**: Full-text search across webhook payloads
- **Replay system**: Resend any webhook to any URL
### Modern UI/UX
- **Real-time updates** with HTMX and Server-Sent Events
- **Dark/light mode** with system preference detection
- **Responsive design** optimized for mobile and desktop
- **Syntax highlighting** for JSON payloads with Prism.js
---
## Platform Features
This template is optimized for modern cloud platforms:
- ✅ **One-click deployment** with railway.json template
- ✅ **Environment variable management** for configuration
- ✅ **Automatic persistent storage** via Railway volumes
- ✅ **Health checks** for service monitoring
- ✅ **Internal networking** for multi-service communication
- ✅ **Production security** with admin token protection
- ✅ **Modern web stack** (FastAPI + HTMX + TailwindCSS)
- ✅ **Cross-platform compatibility** (Unix/Windows/PowerShell)
---
## License
MIT License - Use freely for personal and commercial projects.

377
SIMPLE_TESTING.md Normal file
View File

@ -0,0 +1,377 @@
# Simple Testing Guide - Webhook Catcher
Quick commands to test the webhook catcher deployment.
**Replace `YOUR-DEPLOYMENT-URL` with your actual deployment URL**
## 🧪 Basic Tests
### Health Check
```bash
# Unix/Linux/macOS
curl https://YOUR-DEPLOYMENT-URL.up.railway.app/healthz
# Windows CMD
curl https://YOUR-DEPLOYMENT-URL.up.railway.app/healthz
# Windows PowerShell
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/healthz"
```
### List Available Webhooks (for replay testing)
```bash
# Unix/Linux/macOS
curl https://YOUR-DEPLOYMENT-URL.up.railway.app/webhooks
# Windows CMD
curl https://YOUR-DEPLOYMENT-URL.up.railway.app/webhooks
# Windows PowerShell
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhooks"
```
### Simple Webhook Test
```bash
# Unix/Linux/macOS
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" \
-H "Content-Type: application/json" \
-d '{"event": "test", "message": "Hello World!"}'
# Windows CMD
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" ^
-H "Content-Type: application/json" ^
-d "{\"event\": \"test\", \"message\": \"Hello World!\"}"
```
```powershell
# Windows PowerShell
$body = @{
event = "test"
message = "Hello World!"
timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
} | ConvertTo-Json
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" `
-Method POST -Body $body -ContentType "application/json"
```
### Complex JSON Test
```bash
# Unix/Linux/macOS
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" \
-H "Content-Type: application/json" \
-d '{"user": {"id": 123, "name": "Hello"}, "event": "signup"}'
# Windows CMD
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" ^
-H "Content-Type: application/json" ^
-d "{\"user\": {\"id\": 123, \"name\": \"Hello\"}, \"event\": \"signup\"}"
```
```powershell
# Windows PowerShell
$body = @{
user = @{
id = 123
name = "testuser"
email = "test@example.com"
}
event = "signup"
timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
} | ConvertTo-Json -Depth 3
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" `
-Method POST -Body $body -ContentType "application/json"
```
### GitHub-style Test
```bash
# Unix/Linux/macOS
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-d '{"action": "opened", "repository": {"name": "test-repo"}}'
# Windows CMD
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" ^
-H "Content-Type: application/json" ^
-H "X-GitHub-Event: push" ^
-d "{\"action\": \"opened\", \"repository\": {\"name\": \"test-repo\"}}"
```
```powershell
# Windows PowerShell
$body = @{
action = "opened"
repository = @{
name = "test-repo"
full_name = "user/test-repo"
}
pull_request = @{
title = "Test Pull Request"
user = @{ login = "testuser" }
}
} | ConvertTo-Json -Depth 3
$headers = @{
"Content-Type" = "application/json"
"X-GitHub-Event" = "push"
}
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" `
-Method POST -Body $body -Headers $headers
```
## 🔒 Admin Protection Tests
### Check Admin Status
```bash
# Unix/Linux/macOS
curl https://YOUR-DEPLOYMENT-URL.up.railway.app/healthz
# Windows CMD
curl https://YOUR-DEPLOYMENT-URL.up.railway.app/healthz
# Windows PowerShell
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/healthz"
```
**Look for**: `"admin_protected": true/false`
### Test Clear Without Token (Should Fail if Protected)
```bash
# Unix/Linux/macOS
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/clear"
# Windows CMD
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/clear"
# Windows PowerShell
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/clear" -Method POST
```
**Expected if protected**: `401 Unauthorized`
### Test Clear With Admin Token (Should Work)
```bash
# Unix/Linux/macOS
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/clear" \
-H "X-Admin-Token: your-secret-admin-token-123"
# Windows CMD
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/clear" ^
-H "X-Admin-Token: your-secret-admin-token-123"
```
```powershell
# Windows PowerShell
$headers = @{"X-Admin-Token" = "your-secret-admin-token-123"}
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/clear" `
-Method POST -Headers $headers
```
**Expected**: `{"status": "cleared"}`
### Test Replay With Admin Token
**Step 1: Send a webhook to replay**
```powershell
# Windows PowerShell
$testBody = @{
test = "replay"
timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
message = "This will be replayed"
} | ConvertTo-Json
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhook" `
-Method POST -Body $testBody -ContentType "application/json"
```
**Step 2: List webhooks to get the ID**
```powershell
# Windows PowerShell
$webhooks = Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/webhooks"
Write-Host "Available webhooks:"
$webhooks.webhooks | Format-Table id, timestamp, body_preview
```
**Step 3: Replay the webhook (use the correct ID from step 2)**
```bash
# Unix/Linux/macOS - Replace {ID} with actual webhook ID
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/replay/{ID}?target_url=https://httpbin.org/post" \
-H "X-Admin-Token: your-secret-admin-token-123"
# Windows CMD - Replace {ID} with actual webhook ID
curl -X POST "https://YOUR-DEPLOYMENT-URL.up.railway.app/replay/{ID}?target_url=https://httpbin.org/post" ^
-H "X-Admin-Token: your-secret-admin-token-123"
```
```powershell
# Windows PowerShell - Replace {ID} with actual webhook ID from step 2
$webhookId = 1 # Use the actual ID from the list above
$headers = @{"X-Admin-Token" = "your-secret-admin-token-123"}
Invoke-RestMethod -Uri "https://YOUR-DEPLOYMENT-URL.up.railway.app/replay/$webhookId?target_url=https://httpbin.org/post" `
-Method POST -Headers $headers
```
## 🧪 Complete Test Workflow Scripts
### PowerShell Complete Test
Save as `complete-test.ps1`:
```powershell
param(
[string]$BaseUrl = "https://YOUR-DEPLOYMENT-URL.up.railway.app",
[string]$AdminToken = "your-secret-admin-token-123"
)
Write-Host "🚀 Testing Webhook Catcher: $BaseUrl" -ForegroundColor Green
# 1. Health Check
Write-Host "`n1. Health Check" -ForegroundColor Yellow
try {
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz"
Write-Host "✅ Status: $($health.status)" -ForegroundColor Green
Write-Host "✅ Admin Protected: $($health.admin_protected)" -ForegroundColor Green
} catch {
Write-Host "❌ Health check failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
# 2. Send Test Webhook
Write-Host "`n2. Sending Test Webhook" -ForegroundColor Yellow
$testBody = @{
event = "complete-test"
message = "PowerShell test webhook"
timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
source = "PowerShell Script"
} | ConvertTo-Json
try {
$webhookResult = Invoke-RestMethod -Uri "$BaseUrl/webhook" `
-Method POST -Body $testBody -ContentType "application/json"
Write-Host "✅ Webhook sent: $($webhookResult.status)" -ForegroundColor Green
} catch {
Write-Host "❌ Webhook send failed: $($_.Exception.Message)" -ForegroundColor Red
}
# 3. List Available Webhooks
Write-Host "`n3. Listing Available Webhooks" -ForegroundColor Yellow
try {
$webhooks = Invoke-RestMethod -Uri "$BaseUrl/webhooks"
Write-Host "✅ Found $($webhooks.count) webhooks" -ForegroundColor Green
if ($webhooks.count -gt 0) {
$latestId = $webhooks.webhooks[0].id
Write-Host "📋 Latest webhook ID: $latestId" -ForegroundColor Cyan
# 4. Test Replay
Write-Host "`n4. Testing Webhook Replay" -ForegroundColor Yellow
$headers = @{"X-Admin-Token" = $AdminToken}
try {
$replayResult = Invoke-RestMethod -Uri "$BaseUrl/replay/$latestId?target_url=https://httpbin.org/post" `
-Method POST -Headers $headers
Write-Host "✅ Replay successful: $($replayResult.status)" -ForegroundColor Green
Write-Host "📊 Response status: $($replayResult.response_status)" -ForegroundColor Cyan
} catch {
Write-Host "❌ Replay failed: $($_.Exception.Message)" -ForegroundColor Red
}
}
} catch {
Write-Host "❌ Webhook listing failed: $($_.Exception.Message)" -ForegroundColor Red
}
Write-Host "`n🎉 Test complete! Check your webhook UI at: $BaseUrl" -ForegroundColor Magenta
Write-Host "📋 View logs at: $BaseUrl/logs/view" -ForegroundColor Magenta
```
### Unix/Linux/macOS Test Script
Save as `complete-test.sh`:
```bash
#!/bin/bash
BASE_URL="${1:-https://YOUR-DEPLOYMENT-URL.up.railway.app}"
ADMIN_TOKEN="${2:-your-secret-admin-token-123}"
echo "🚀 Testing Webhook Catcher: $BASE_URL"
# 1. Health Check
echo "1. Health Check"
HEALTH=$(curl -s "$BASE_URL/healthz")
echo "✅ Health: $HEALTH"
# 2. Send Test Webhook
echo "2. Sending Test Webhook"
WEBHOOK_RESULT=$(curl -s -X POST "$BASE_URL/webhook" \
-H "Content-Type: application/json" \
-d "{\"event\": \"bash-test\", \"message\": \"Hello from bash!\", \"timestamp\": \"$(date -Iseconds)\"}")
echo "✅ Webhook: $WEBHOOK_RESULT"
# 3. List Webhooks
echo "3. Listing Webhooks"
WEBHOOKS=$(curl -s "$BASE_URL/webhooks")
echo "✅ Webhooks: $WEBHOOKS"
# 4. Get latest webhook ID and replay
LATEST_ID=$(echo "$WEBHOOKS" | jq -r '.webhooks[0].id // empty')
if [ ! -z "$LATEST_ID" ]; then
echo "4. Replaying webhook ID: $LATEST_ID"
REPLAY_RESULT=$(curl -s -X POST "$BASE_URL/replay/$LATEST_ID?target_url=https://httpbin.org/post" \
-H "X-Admin-Token: $ADMIN_TOKEN")
echo "✅ Replay: $REPLAY_RESULT"
fi
echo "🎉 Test complete! Check your webhook UI at: $BASE_URL"
```
## 🌐 Web Interface Testing
1. Visit: `https://YOUR-DEPLOYMENT-URL.up.railway.app/`
2. Check logs: `https://YOUR-DEPLOYMENT-URL.up.railway.app/logs/view`
3. Send test webhook using the built-in form
4. Try search, export, and replay features
5. Test admin operations (clear, replay) with/without protection
## ✅ Expected Results
- All webhook tests return `{"status": "success", ...}`
- Webhooks appear in real-time on the web interface
- `/webhooks` endpoint lists available webhook IDs
- Search and export functions work
- Health check returns `{"status": "ok", "admin_protected": true/false}`
- Admin operations respect token protection if enabled
- Replay works with correct webhook IDs from `/webhooks` endpoint
## 🚨 Common Issues & Solutions
### Issue: "Webhook not found" during replay
**Solution**: Use `GET /webhooks` to see available webhook IDs first
### Issue: "Internal Server Error" during replay
**Solution**: Check that the target URL is valid (starts with http:// or https://)
### Issue: Admin token not working
**Solution**: Ensure ADMIN_TOKEN environment variable is set correctly
### Issue: No webhooks showing up
**Solution**: Send a test webhook first using the `/webhook` endpoint
## 🏆 Key Features to Evaluate
- **Multi-service architecture** (if FORWARD_WEBHOOK_URL is configured)
- **Real-time web interface** with live updates
- **Multiple webhook formats** (JSON, plain text, form data)
- **Production features** (search, export, replay, admin protection)
- **Security features** (admin token protection for sensitive operations)
- **Cross-platform compatibility** (Unix, Windows CMD, PowerShell)
- **Error handling** (proper HTTP status codes and error messages)
Total testing time: ~5 minutes
---
## 📝 Quick Setup
⚡ This project is optimized for Railway, but you can deploy it anywhere you like.
1. Deploy using your preferred platform (Railway recommended for easiest setup)
2. Copy your deployment URL from your hosting dashboard
3. Replace `YOUR-DEPLOYMENT-URL` in commands above
4. Run tests to evaluate the webhook catcher
5. Test on your preferred platform (Unix/Windows/PowerShell)
6. Use `/webhooks` endpoint to get valid IDs before replay testing

654
app/main.py Normal file
View File

@ -0,0 +1,654 @@
from fastapi import FastAPI, Request, HTTPException, Query, Depends
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from datetime import datetime
import sqlite3
import json
import httpx
import os
import csv
import base64
from io import StringIO
from urllib.parse import urlparse
import asyncio
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = FastAPI(title="Webhook Catcher")
app.mount(
"/static",
StaticFiles(directory=os.path.join(BASE_DIR, "static")),
name="static"
)
templates = Jinja2Templates(
directory=os.path.join(BASE_DIR, "templates")
)
templates.env.filters["tojson"] = lambda value, indent=2: json.dumps(value, indent=indent)
SENSITIVE_HEADERS = {'authorization', 'cookie', 'x-api-key', 'api-key'}
security = HTTPBasic()
FORWARD_WEBHOOK_URL = os.getenv("FORWARD_WEBHOOK_URL")
FORWARD_WEBHOOK_TOKEN = os.getenv("FORWARD_WEBHOOK_TOKEN")
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN")
FRONTEND_PASSWORD = os.getenv("FRONTEND_PASSWORD")
# Database configuration - single source of truth for DB path
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
DB_PATH = os.path.join(DATA_DIR, "webhooks.db")
def verify_admin_token(request: Request) -> bool:
"""Verify admin token from header or query parameter"""
if not ADMIN_TOKEN or ADMIN_TOKEN.strip() == "":
logger.info("No ADMIN_TOKEN set or empty, allowing access")
return True
token = request.headers.get("X-Admin-Token")
if token and token == ADMIN_TOKEN:
logger.info("Valid admin token from header")
return True
token = request.query_params.get("admin_token")
if token and token == ADMIN_TOKEN:
logger.info("Valid admin token from query")
return True
logger.warning("Admin token verification failed")
return False
def require_admin(request: Request):
"""Dependency to require admin authentication"""
if not verify_admin_token(request):
logger.warning("Admin access denied")
raise HTTPException(
status_code=401,
detail="Admin token required. Set X-Admin-Token header or admin_token query parameter.",
headers={"WWW-Authenticate": "Bearer"}
)
return True
def get_optional_credentials(request: Request):
"""Get HTTP Basic credentials if provided, or None if not."""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Basic "):
return None
try:
credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
username, _, password = credentials.partition(":")
return HTTPBasicCredentials(username=username, password=password)
except Exception:
return None
def verify_frontend_password(request: Request):
"""Dependency to require frontend password via HTTP Basic Auth.
If FRONTEND_PASSWORD is not set, access is allowed without authentication.
Username can be anything; only the password is checked.
"""
if not FRONTEND_PASSWORD or FRONTEND_PASSWORD.strip() == "":
return True # No protection if password not set
credentials = get_optional_credentials(request)
if credentials and credentials.password == FRONTEND_PASSWORD:
return True
raise HTTPException(
status_code=401,
detail="Invalid password",
headers={"WWW-Authenticate": "Basic realm=\"Webhook Catcher\""}
)
def sanitize_headers(headers):
"""Redact sensitive header values"""
return {k: '***REDACTED***' if k.lower() in SENSITIVE_HEADERS else v
for k, v in headers.items()}
def validate_url(url: str) -> bool:
"""Basic URL validation to prevent SSRF"""
try:
result = urlparse(url)
return all([result.scheme in ['http', 'https'], result.netloc])
except:
return False
def try_json(data: str):
"""Safely parse JSON data"""
try:
return json.loads(data)
except:
return None
def extract_metadata(headers: dict) -> dict:
"""Extract useful metadata from headers"""
return {
"ip": headers.get("x-real-ip") or headers.get("x-forwarded-for") or "Unknown",
"user_agent": headers.get("user-agent", "Unknown"),
"source": headers.get("x-webhook-source", "Unknown"),
"timestamp": headers.get("x-request-start", None)
}
def format_timestamp(timestamp):
"""Format timestamp consistently with relative time"""
try:
dt = datetime.fromisoformat(timestamp)
return {
"iso": dt.isoformat(),
"display": dt.strftime("%Y-%m-%d %H:%M:%S")
}
except:
return {
"iso": timestamp,
"display": timestamp
}
def init_db():
# Ensure data directory exists
os.makedirs(DATA_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS webhooks
(id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT,
headers TEXT,
body TEXT)
''')
conn.commit()
conn.close()
@app.on_event("startup")
async def startup_event():
init_db()
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, _: bool = Depends(verify_frontend_password)):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/healthz")
async def health_check():
"""Simple health check endpoint"""
try:
conn = sqlite3.connect(DB_PATH)
conn.close()
return {"status": "ok", "admin_protected": bool(ADMIN_TOKEN and ADMIN_TOKEN.strip())}
except Exception as e:
logger.error(f"Health check failed: {e}")
return {"status": "error", "error": str(e)}
async def forward_webhook(headers: dict, body: str, original_url: str) -> dict:
"""Forward webhook to configured endpoint if enabled"""
if not FORWARD_WEBHOOK_URL:
return {"status": "disabled", "message": "Forwarding not configured"}
try:
forward_headers = {
"Content-Type": "application/json",
"X-Forwarded-From": original_url,
"X-Original-Timestamp": datetime.now().isoformat()
}
if FORWARD_WEBHOOK_TOKEN:
forward_headers["Authorization"] = f"Bearer {FORWARD_WEBHOOK_TOKEN}"
safe_headers = {k: v for k, v in headers.items()
if k.lower() not in SENSITIVE_HEADERS}
forward_headers.update({f"X-Original-{k}": v for k, v in safe_headers.items()})
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
FORWARD_WEBHOOK_URL,
content=body,
headers=forward_headers
)
return {
"status": "success",
"target_url": FORWARD_WEBHOOK_URL,
"response_status": response.status_code,
"response_time_ms": int(response.elapsed.total_seconds() * 1000)
}
except Exception as e:
return {
"status": "error",
"target_url": FORWARD_WEBHOOK_URL,
"error": str(e)
}
@app.post("/webhook")
async def webhook(request: Request):
try:
body = await request.body()
headers = dict(request.headers)
body_str = ""
if body:
try:
body_str = body.decode('utf-8')
except UnicodeDecodeError:
body_str = body.decode('utf-8', errors='replace')
else:
body_str = "{}"
parsed_json = None
try:
if body_str.strip():
parsed_json = json.loads(body_str)
except json.JSONDecodeError:
pass
print(f"Received webhook: {body_str}")
print(f"Headers: {headers}")
forward_task = None
if FORWARD_WEBHOOK_URL:
forward_task = asyncio.create_task(
forward_webhook(headers, body_str, str(request.url))
)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
print("Inserting into database...")
c.execute(
"INSERT INTO webhooks (timestamp, headers, body) VALUES (?, ?, ?)",
(datetime.now().isoformat(), json.dumps(headers), body_str)
)
conn.commit()
conn.close()
print("Database insert complete")
forwarding_result = None
if forward_task:
try:
forwarding_result = await forward_task
print(f"Forwarding result: {forwarding_result}")
except Exception as e:
print(f"Forwarding failed: {e}")
forwarding_result = {"status": "error", "error": str(e)}
response_data = {
"status": "success",
"timestamp": datetime.now().isoformat(),
"received_body": body_str,
"is_json": parsed_json is not None,
"forwarding": forwarding_result
}
return response_data
except Exception as e:
print(f"Error processing webhook: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to process webhook: {str(e)}"
)
def get_webhook_logs(offset: int = 0, limit: int = 20, search: str = None):
"""Helper function to fetch webhook logs"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
if search:
search = search.strip()
search_terms = [f"%{term}%" for term in search.split()]
where_clauses = []
params = []
for term in search_terms:
where_clauses.extend([
"body LIKE ?",
"headers LIKE ?",
"timestamp LIKE ?"
])
params.extend([term, term, term])
where_sql = " OR ".join(where_clauses)
c.execute(f"SELECT COUNT(*) FROM webhooks WHERE {where_sql}", params)
total_count = c.fetchone()[0]
c.execute(f"""
SELECT * FROM webhooks
WHERE {where_sql}
ORDER BY timestamp DESC LIMIT ? OFFSET ?
""", params + [limit, offset])
else:
c.execute("SELECT COUNT(*) FROM webhooks")
total_count = c.fetchone()[0]
c.execute("""
SELECT * FROM webhooks
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
""", (limit, offset))
logs = [
{
"id": row[0],
"timestamp": format_timestamp(row[1]),
"headers": sanitize_headers(json.loads(row[2])),
"metadata": extract_metadata(json.loads(row[2])),
"body": row[3],
"parsed_body": try_json(row[3]),
"matches": highlight_search_matches(row[3], search) if search else None
}
for row in c.fetchall()
]
return logs, total_count, (offset + len(logs)) < total_count
finally:
conn.close()
def highlight_search_matches(text: str, search: str) -> list:
"""Find and highlight search matches in text"""
if not search:
return []
matches = []
try:
search_terms = search.lower().split()
text_lower = text.lower()
for term in search_terms:
start = 0
while True:
pos = text_lower.find(term, start)
if pos == -1:
break
context_start = max(0, pos - 20)
context_end = min(len(text), pos + len(term) + 20)
matches.append({
"term": term,
"context": f"...{text[context_start:context_end]}..."
})
start = pos + len(term)
return matches
except:
return []
@app.get("/logs/view", response_class=HTMLResponse)
async def view_logs(request: Request, _: bool = Depends(verify_frontend_password)):
"""Full logs page view"""
logs, total_count, has_more = get_webhook_logs(limit=10)
return templates.TemplateResponse("logs.html", {
"request": request,
"logs": logs,
"count": len(logs),
"total_count": total_count,
"has_more": has_more,
"offset": 0,
"limit": 10
})
@app.get("/logs")
async def get_logs(
request: Request,
search: str = Query(None),
offset: int = Query(0),
limit: int = Query(20),
_: bool = Depends(verify_frontend_password)
):
"""Partial logs view for HTMX updates"""
try:
logs, total_count, has_more = get_webhook_logs(offset, limit, search)
print(f"Retrieved logs: count={len(logs)}, total={total_count}")
template_data = {
"request": request,
"logs": logs,
"count": total_count,
"total_count": total_count,
"has_more": has_more,
"offset": offset,
"limit": limit,
"empty": len(logs) == 0
}
if "application/json" in request.headers.get("accept", ""):
return {
"logs": logs,
"count": total_count,
"total_count": total_count,
"has_more": has_more,
"empty": len(logs) == 0
}
return templates.TemplateResponse("logs_list.html", template_data)
except Exception as e:
print(f"Error retrieving logs: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/export")
async def export_logs(request: Request, format: str = Query("json", enum=["json", "csv"]), _: bool = Depends(verify_frontend_password)):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT * FROM webhooks ORDER BY timestamp DESC")
logs = [{"id": row[0], "timestamp": row[1], "headers": json.loads(row[2]), "body": row[3]}
for row in c.fetchall()]
conn.close()
if format == "csv":
output = StringIO()
writer = csv.DictWriter(output, fieldnames=["id", "timestamp", "headers", "body"])
writer.writeheader()
writer.writerows(logs)
return Response(
content=output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=webhooks.csv"}
)
return Response(
content=json.dumps(logs, indent=2, ensure_ascii=False),
media_type="application/json",
headers={
"Content-Disposition": "attachment; filename=webhooks.json",
"X-Total-Count": str(len(logs))
}
)
@app.post("/test")
async def test_webhook(request: Request):
try:
body = await request.body()
if not body or body == b'{}':
payload = {
"event": "test",
"timestamp": datetime.now().isoformat(),
"message": "Test webhook payload"
}
else:
try:
payload = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="Invalid JSON payload. Example: {'event': 'test', 'message': 'Hello'}"
)
base_url = str(request.base_url)
if base_url.startswith('http:'):
base_url = 'https:' + base_url[5:]
webhook_url = base_url + "webhook"
async with httpx.AsyncClient() as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"}
)
return {
"status": "sent",
"response_status": response.status_code,
"url": webhook_url,
"payload": payload
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to send test webhook: {str(e)}"
)
@app.post("/replay/{webhook_id}")
async def replay_webhook(webhook_id: int, request: Request, target_url: str = Query(None)):
"""Replay webhook with optional admin protection - accepts target_url from query or body"""
if ADMIN_TOKEN and not verify_admin_token(request):
raise HTTPException(
status_code=401,
detail="Admin token required for replay operations",
headers={"WWW-Authenticate": "Bearer"}
)
if not target_url:
try:
body = await request.body()
if body:
body_data = json.loads(body)
target_url = body_data.get("target_url")
except (json.JSONDecodeError, AttributeError):
pass
if not target_url:
raise HTTPException(
status_code=400,
detail="target_url is required. Provide it as a query parameter (?target_url=...) or in request body as JSON {\"target_url\": \"...\"}"
)
if not validate_url(target_url):
raise HTTPException(
status_code=400,
detail="Invalid target URL. Must be http(s)://..."
)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT headers, body FROM webhooks WHERE id = ?", (webhook_id,))
row = c.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail=f"Webhook {webhook_id} not found")
headers = json.loads(row[0])
body = row[1]
replay_headers = {k: v for k, v in headers.items()
if k.lower() not in ['host', 'content-length', 'connection']}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
target_url,
headers=replay_headers,
content=body
)
return {
"status": "replayed",
"response_status": response.status_code,
"target_url": target_url,
"webhook_id": webhook_id
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to replay webhook: {str(e)}"
)
@app.post("/clear")
async def clear_logs(request: Request):
"""Clear logs with optional admin protection"""
if ADMIN_TOKEN and not verify_admin_token(request):
raise HTTPException(
status_code=401,
detail="Admin token required for clear operations",
headers={"WWW-Authenticate": "Bearer"}
)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("DELETE FROM webhooks")
conn.commit()
conn.close()
return {"status": "cleared"}
@app.get("/config")
async def get_config():
"""Get current configuration status"""
return {
"forwarding_enabled": bool(FORWARD_WEBHOOK_URL),
"forwarding_url": FORWARD_WEBHOOK_URL if FORWARD_WEBHOOK_URL else None,
"authentication_enabled": bool(FORWARD_WEBHOOK_TOKEN),
"admin_protection_enabled": bool(ADMIN_TOKEN and ADMIN_TOKEN.strip()),
"total_webhooks": get_total_webhook_count()
}
def get_total_webhook_count():
"""Get total count of webhooks received"""
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM webhooks")
count = c.fetchone()[0]
conn.close()
return count
except:
return 0
@app.get("/webhooks")
async def list_webhooks(request: Request, limit: int = Query(50), _: bool = Depends(verify_frontend_password)):
"""List available webhooks for replay testing"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM webhooks")
total_count = c.fetchone()[0]
c.execute("""
SELECT id, timestamp, body, headers
FROM webhooks
ORDER BY timestamp DESC
LIMIT ?
""", (limit,))
webhooks = []
for row in c.fetchall():
webhook_id, timestamp, body, headers = row
body_preview = body[:100] + "..." if len(body) > 100 else body
headers_dict = json.loads(headers)
content_type = headers_dict.get("content-type", "unknown")
webhooks.append({
"id": webhook_id,
"timestamp": timestamp,
"body_preview": body_preview,
"content_type": content_type,
"size_bytes": len(body)
})
conn.close()
return {
"webhooks": webhooks,
"count": len(webhooks),
"total_count": total_count
}

277
app/static/css/ui.css Normal file
View File

@ -0,0 +1,277 @@
/* =========================
Base / Reset
========================= */
html,
body {
min-height: 100vh;
margin: 0;
padding: 0;
}
* {
transition: background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
pre {
white-space: pre-wrap;
word-break: break-word;
}
/* =========================
Dark Mode Base
========================= */
html.dark,
html.dark body {
background-color: #111827 !important;
}
.dark {
color: #ffffff !important;
}
.dark * {
text-shadow: none !important;
-webkit-text-stroke: none !important;
}
/* =========================
Background Overrides
========================= */
.dark .bg-white {
background-color: #1f2937 !important;
color: #ffffff !important;
}
.dark .bg-gray-50,
.dark .bg-gray-100 {
background-color: #374151 !important;
color: #ffffff !important;
}
/* =========================
Text Colors
========================= */
.dark .text-gray-800 { color: #ffffff !important; }
.dark .text-gray-700 { color: #e5e7eb !important; }
.dark .text-gray-600 { color: #d1d5db !important; }
.dark .text-gray-500,
.dark .text-gray-400 { color: #9ca3af !important; }
/* =========================
Borders
========================= */
.dark .border,
.dark .border-gray-200 {
border-color: #374151 !important;
}
.dark .border-gray-700 {
border-color: #4b5563 !important;
}
/* =========================
Forms
========================= */
.dark input,
.dark textarea,
.dark select {
background-color: #374151 !important;
color: #ffffff !important;
border-color: #4b5563 !important;
}
.dark input::placeholder,
.dark textarea::placeholder {
color: #9ca3af !important;
}
/* =========================
Code / Prism
========================= */
.dark pre {
background-color: #1a1a1a !important;
}
.dark pre code {
background-color: #1a1a1a !important;
color: #ffffff !important;
}
.dark code {
background-color: #374151 !important;
color: #ffffff !important;
}
/* =========================
Hover Fixes (Tailwind Escaped)
========================= */
.dark .hover\:bg-gray-50:hover {
background-color: #374151 !important;
}
.dark .hover\:bg-gray-200:hover {
background-color: #4b5563 !important;
}
/* =========================
Headers / Buttons
========================= */
.dark h1,
.dark h2,
.dark h3,
.dark h4,
.dark h5,
.dark h6,
.dark button,
.dark a {
color: #ffffff !important;
text-shadow: none !important;
}
/* =========================
Sticky Header
========================= */
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(8px);
background-color: rgba(255, 255, 255, 0.95);
}
.dark .sticky-header {
background-color: rgba(31, 41, 55, 0.95) !important;
color: #ffffff !important;
}
/* =========================
Cards / Sections
========================= */
.card {
background: rgba(255, 255, 255, 0.9);
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.dark .card {
background: rgba(30, 41, 59, 0.9);
}
.content-section {
margin-bottom: 1.5rem;
padding: 1.5rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.dark .content-section {
background: #1f2937;
}
/* =========================
Glass Effect
========================= */
.glass-bg {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
}
.dark .glass-bg {
background: rgba(31, 41, 55, 0.8);
}
/* =========================
Layout Helpers
========================= */
.content-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.section-spacing {
margin-bottom: 2rem;
}
.main-header {
padding: 1rem 0;
margin-bottom: 2rem;
}
/* =========================
Animations
========================= */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.animate-in {
animation: fadeIn 0.3s ease-out;
}
.fade-up {
animation: fadeUp 0.3s ease-out;
}
.loading-pulse {
animation: pulse 1.5s infinite;
}
.hover-scale {
transition: transform 0.2s ease;
}
.hover-scale:hover {
transform: scale(1.02);
}
/* =========================
Webhook Items
========================= */
.dark .webhook-item {
border-color: #374151 !important;
}
/* =========================
Highlight
========================= */
.highlight {
background-color: #fef08a;
}
.dark .highlight {
background-color: #92400e !important;
color: #ffffff !important;
}

601
app/templates/index.html Normal file
View File

@ -0,0 +1,601 @@
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8">
<title>Webhook Catcher</title>
<script src="https://unpkg.com/htmx.org@1.9.3"></script>
<script src="https://unpkg.com/hyperscript.org@0.9.9"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.10/dayjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.10/plugin/relativeTime.js"></script>
<link rel="stylesheet" href="/static/css/ui.css?v=2026-01-13">
</head>
<body class="min-h-screen bg-gray-100 dark:bg-gray-900">
<div id="admin-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Admin Authentication Required</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4">This operation requires admin privileges. Please enter your admin token:</p>
<input
type="password"
id="admin-token-input"
placeholder="Enter admin token..."
class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white mb-4 focus:ring-2 focus:ring-blue-500 outline-none">
<div class="flex gap-2 justify-end">
<button
onclick="cancelAdminModal()"
class="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition">
Cancel
</button>
<button
onclick="submitAdminToken()"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition">
Submit
</button>
</div>
</div>
</div>
<div class="sticky-header">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-4">
<h1 class="text-4xl font-bold text-blue-600">Webhook Catcher 🚀</h1>
<a href="/logs/view" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">📋 Logs</a>
<span id="admin-status" class="px-2 py-1 text-xs rounded hidden">
🔒 Admin Protected
</span>
</div>
<button onclick="toggleDarkMode()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg transition-colors flex items-center gap-2">
<span class="dark:hidden">🌙</span>
<span class="hidden dark:inline">☀️</span>
<span class="dark:hidden">Dark</span>
<span class="hidden dark:inline">Light</span>
</button>
</div>
</div>
<div class="max-w-6xl mx-auto p-8">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8">
<h2 class="text-xl font-semibold mb-4">🚀 Webhook Catcher Status</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-300 mb-1">Webhook URL</div>
<code class="text-sm bg-gray-100 dark:bg-gray-600 p-1 rounded block truncate" id="webhook-url-display"></code>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-300 mb-1">Total Received</div>
<span id="total-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">-</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8">
<h2 class="text-xl font-semibold mb-4">📥 Your Webhook URL</h2>
<div class="flex items-center gap-2">
<code class="bg-gray-100 dark:bg-gray-700 p-3 rounded flex-1 text-sm" id="webhook-url"></code>
<button onclick="copyWebhookUrl()" class="px-4 py-3 bg-blue-500 text-white rounded hover:bg-blue-600 transition">
📋 Copy
</button>
</div>
</div>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8">
<details class="group">
<summary class="text-xl font-semibold mb-4 cursor-pointer hover:text-blue-600 transition-colors">
📚 Quick Start Guide
</summary>
<div class="mt-4 space-y-4">
<h3 class="text-lg font-medium">Test with cURL</h3>
<div class="space-y-4">
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg">
<h4 class="text-sm font-medium mb-2">PowerShell/Terminal:</h4>
<pre class="text-sm overflow-x-auto"><code>curl -X POST <span id="curl-url-powershell"></span>/webhook -H "Content-Type: application/json" -d "{\"event\": \"test\", \"message\": \"Hello World!\"}"</code></pre>
<button onclick="copyCurlCommand('powershell')" class="mt-2 px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition">
📋 Copy Command
</button>
</div>
</div>
<h3 class="text-lg font-medium mt-6">Common Use Cases</h3>
<ul class="list-disc pl-5 space-y-2 text-gray-700 dark:text-gray-300">
<li>GitHub repository events and CI/CD pipelines</li>
<li>Discord/Slack bot development and testing</li>
<li>Payment processing (Stripe, PayPal) notifications</li>
<li>IoT device updates and monitoring</li>
</ul>
<h3 class="text-lg font-medium mt-6">How to Use</h3>
<ol class="list-decimal pl-5 space-y-2 text-gray-700 dark:text-gray-300">
<li>Copy your webhook URL from above</li>
<li>Add it to your service (GitHub, Discord, Stripe, etc.)</li>
<li>Send webhooks and view them in real-time below</li>
<li>Use search, replay, or export features as needed</li>
</ol>
</div>
</details>
</div>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8">
<h2 class="text-xl font-semibold mb-4">🧪 Test Your Setup</h2>
<div class="mb-4">
<textarea
id="test-payload"
class="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600"
rows="4"
placeholder='{"event": "test", "message": "Hello World!", "timestamp": "2025-01-31T12:00:00Z"}'
></textarea>
</div>
<button
onclick="sendTestWebhook()"
class="px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 transition">
🚀 Send Test Webhook
</button>
</div>
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 mb-6">
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<h2 class="text-xl font-semibold">📋 Webhook Logs</h2>
<input
type="search"
name="search"
placeholder="🔍 Search logs..."
class="px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600"
hx-get="/logs"
hx-trigger="keyup changed delay:500ms"
hx-target="#logs">
</div>
<div class="flex flex-wrap gap-2">
<a href="/export?format=json" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition">
📥 JSON
</a>
<a href="/export?format=csv" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition">
📥 CSV
</a>
<button
onclick="clearLogsWithModal()"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition">
🗑️ Clear
</button>
</div>
</div>
<div class="log-count text-sm text-gray-600 dark:text-gray-400 mb-2">Loading logs...</div>
<div id="logs"
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden"
hx-get="/logs"
hx-trigger="load, every 5s">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<div class="animate-spin h-8 w-8 mx-auto mb-4">
<svg class="text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<p>Loading webhooks...</p>
</div>
</div>
</div>
<script>
dayjs.extend(window.dayjs_plugin_relativeTime);
document.addEventListener('DOMContentLoaded', function() {
const webhookUrl = document.getElementById('webhook-url');
const curlUrl = document.getElementById('curl-url');
if (webhookUrl) webhookUrl.textContent = window.location.origin + '/webhook';
if (curlUrl) curlUrl.textContent = window.location.origin;
const baseUrl = window.location.origin;
['unix', 'windows', 'powershell'].forEach(type => {
const el = document.getElementById(`curl-url-${type}`);
if (el) el.textContent = baseUrl;
});
const webhookUrlDisplay = document.getElementById('webhook-url-display');
if (webhookUrlDisplay) {
webhookUrlDisplay.textContent = window.location.origin + '/webhook';
}
loadConfigStatus();
setInterval(loadConfigStatus, 30000);
});
function copyWebhookUrl() {
const url = window.location.origin + '/webhook';
navigator.clipboard.writeText(url);
const btn = event.target;
btn.innerText = '✅ Copied!';
setTimeout(() => btn.innerText = '📋 Copy', 2000);
}
function formatJSON(str) {
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch {
return str;
}
}
async function executeAdminOperation(operation, operationName) {
try {
let result = await operation();
return result;
} catch (error) {
console.log(`Operation ${operationName} failed:`, error);
const is401 = error.status === 401 ||
(error.response && error.response.status === 401) ||
(error.message && error.message.includes('401')) ||
(error.message && error.message.includes('Admin token required'));
if (is401) {
console.log('401 detected, showing admin modal');
return new Promise((resolve, reject) => {
pendingAdminOperation = async () => {
try {
console.log('Retrying operation with token:', currentAdminToken ? 'provided' : 'missing');
const result = await operation(currentAdminToken);
resolve(result);
} catch (retryError) {
console.log('Retry failed:', retryError);
const isRetry401 = retryError.status === 401 ||
(retryError.response && retryError.response.status === 401) ||
(retryError.message && retryError.message.includes('401')) ||
(retryError.message && retryError.message.includes('Admin token required'));
if (isRetry401) {
alert('❌ Invalid admin token. Please try again.');
currentAdminToken = null;
showAdminModal(pendingAdminOperation);
} else {
alert(`❌ ${operationName} failed: ${retryError.message}`);
reject(retryError);
}
}
};
showAdminModal(pendingAdminOperation);
});
} else {
throw error;
}
}
}
async function replayWebhook(btn) {
if (!btn) return;
const targetUrl = btn.previousElementSibling?.value?.trim();
const webhookId = btn.closest('.webhook-item')?.dataset?.id;
if (!targetUrl) {
alert('Please enter a target URL');
return;
}
if (!webhookId) {
console.error('No webhook ID found');
return;
}
console.log(`Attempting to replay webhook ${webhookId} to ${targetUrl}`);
btn.innerHTML = '⏳ Sending...';
btn.disabled = true;
try {
await executeAdminOperation(async (token) => {
console.log('Executing replay operation, token provided:', !!token);
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['X-Admin-Token'] = token;
console.log('Added admin token to headers');
}
const response = await fetch(`/replay/${webhookId}?target_url=${encodeURIComponent(targetUrl)}`, {
method: 'POST',
headers: headers
});
console.log('Replay response status:', response.status);
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
errorMessage = response.statusText || errorMessage;
}
const err = new Error(errorMessage);
err.status = response.status;
err.response = response;
console.log('Throwing error with status:', err.status);
throw err;
}
return await response.json();
}, 'Replay webhook');
console.log('Replay successful');
btn.innerHTML = '✅ Sent!';
btn.className = 'px-4 py-2 bg-green-500 text-white rounded';
} catch (error) {
console.error('Final replay error:', error);
if (!error.status || error.status !== 401) {
btn.innerHTML = '❌ Failed!';
btn.className = 'px-4 py-2 bg-red-500 text-white rounded';
alert(`Replay failed: ${error.message}`);
}
}
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '🔄 Replay';
btn.className = 'px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition';
}, 2000);
}
async function sendTestWebhook() {
const textarea = document.getElementById('test-payload');
let payload = textarea.value.trim();
try {
if (!payload) {
payload = '{}';
}
JSON.parse(payload);
const response = await fetch('/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Failed to send webhook');
}
alert(`✅ Test webhook sent!\nStatus: ${result.response_status}\nURL: ${result.url}`);
} catch (error) {
if (error instanceof SyntaxError) {
alert('❌ Invalid JSON format. Example: {"event": "test", "message": "Hello"}');
} else {
alert(`❌ ${error.message || 'Failed to send test webhook'}`);
}
console.error(error);
}
}
function highlightSearch(text, search) {
if (!search) return text;
const regex = new RegExp(`(${search})`, 'gi');
return text.replace(regex, '<span class="highlight">$1</span>');
}
function formatRelativeTime(timestamp) {
return dayjs(timestamp).fromNow();
}
function toggleDarkMode() {
const html = document.documentElement;
const isDark = html.classList.toggle('dark');
localStorage.setItem('darkMode', isDark);
const currentScroll = window.scrollY;
requestAnimationFrame(() => window.scrollTo(0, currentScroll));
}
async function loadConfigStatus() {
try {
const response = await fetch('/config');
const config = await response.json();
document.getElementById('total-count').textContent = config.total_webhooks;
const adminStatus = document.getElementById('admin-status');
if (config.admin_protection_enabled) {
adminStatus.classList.remove('hidden');
adminStatus.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
} else {
adminStatus.classList.add('hidden');
}
} catch (error) {
console.error('Failed to load config:', error);
document.getElementById('total-count').textContent = '0';
}
}
if (localStorage.getItem('darkMode') === null) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
localStorage.setItem('darkMode', 'true');
}
} else if (localStorage.getItem('darkMode') === 'true') {
document.documentElement.classList.add('dark');
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (localStorage.getItem('darkMode') === null) {
document.documentElement.classList.toggle('dark', e.matches);
}
});
function getDefaultReplayUrl() {
return `${window.location.origin}/webhook`;
}
document.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail.target;
if (!target) return;
const logCount = document.querySelector('.log-count');
const webhookItems = target.querySelectorAll('.webhook-item');
if (logCount && webhookItems) {
const total = webhookItems.length;
logCount.textContent = `Showing ${total} log${total !== 1 ? 's' : ''}`;
}
target.querySelectorAll('input[type="url"]').forEach(input => {
if (!input.value) {
input.value = window.location.origin + '/webhook';
}
});
const searchTerm = document.querySelector('input[name="search"]')?.value;
target.querySelectorAll('pre code').forEach((el) => {
let content = el.textContent;
try {
content = JSON.stringify(JSON.parse(content), null, 2);
} catch (e) {
// JSON parsing failed - display as-is (may be invalid JSON or plain text)
}
el.textContent = content;
if (searchTerm) {
el.innerHTML = highlightSearch(content, searchTerm);
}
try {
Prism.highlightElement(el);
} catch (e) {
console.error('Prism highlighting error:', e);
}
});
});
function copyCurlCommand(type) {
const baseUrl = window.location.origin;
const curlCmd = `curl -X POST ${baseUrl}/webhook -H "Content-Type: application/json" -d "{\\"event\\": \\"test\\", \\"message\\": \\"Hello World\\"}"`;
navigator.clipboard.writeText(curlCmd);
const btn = event.target;
btn.innerText = '✅ Copied!';
setTimeout(() => btn.innerText = '📋 Copy Command', 2000);
}
let currentAdminToken = null;
let pendingAdminOperation = null;
function showAdminModal(operation) {
pendingAdminOperation = operation;
document.getElementById('admin-modal').classList.remove('hidden');
document.getElementById('admin-token-input').focus();
}
function cancelAdminModal() {
document.getElementById('admin-modal').classList.add('hidden');
document.getElementById('admin-token-input').value = '';
pendingAdminOperation = null;
currentAdminToken = null;
}
function submitAdminToken() {
const token = document.getElementById('admin-token-input').value.trim();
if (!token) {
alert('Please enter a token');
return;
}
currentAdminToken = token;
document.getElementById('admin-modal').classList.add('hidden');
document.getElementById('admin-token-input').value = '';
if (pendingAdminOperation) {
pendingAdminOperation();
pendingAdminOperation = null;
}
}
async function clearLogsWithModal() {
const confirmed = confirm('Are you sure you want to clear all webhook logs? This action cannot be undone.');
if (!confirmed) return;
try {
console.log('Attempting to clear logs');
const response = await fetch('/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
console.log('Clear response status:', response.status);
if (response.status === 401) {
console.log('Got 401, showing admin modal for clear');
showAdminModal(async () => {
console.log('Retrying clear with admin token');
try {
const retryResponse = await fetch('/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': currentAdminToken
}
});
if (retryResponse.ok) {
alert('✅ Logs cleared successfully!');
document.getElementById('logs').innerHTML = `
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<div class="text-6xl mb-4">📭</div>
<h3 class="text-xl font-bold mb-2">No webhooks yet</h3>
<p>Send a test webhook to get started!</p>
</div>
`;
loadConfigStatus();
} else {
alert('❌ Clear failed - invalid admin token');
}
} catch (retryError) {
alert('❌ Clear failed: ' + retryError.message);
}
});
return;
}
if (response.ok) {
alert('✅ Logs cleared successfully!');
document.getElementById('logs').innerHTML = `
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<div class="text-6xl mb-4">📭</div>
<h3 class="text-xl font-bold mb-2">No webhooks yet</h3>
<p>Send a test webhook to get started!</p>
</div>
`;
loadConfigStatus();
} else {
alert('❌ Clear failed: HTTP ' + response.status);
}
} catch (error) {
console.error('Clear error:', error);
alert('❌ Network error: ' + error.message);
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !document.getElementById('admin-modal').classList.contains('hidden')) {
cancelAdminModal();
}
if (e.key === 'Enter' && !document.getElementById('admin-modal').classList.contains('hidden')) {
submitAdminToken();
}
});
</script>
</body>
</html>

388
app/templates/logs.html Normal file
View File

@ -0,0 +1,388 @@
<head>
<meta charset="UTF-8">
<title>Webhook Logs - Webhook Catcher</title>
<script src="https://unpkg.com/htmx.org@1.9.3"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.10/dayjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.10/plugin/relativeTime.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/ui.css?v=2026-01-13">
</head>
<body class="min-h-screen bg-gray-100 dark:bg-gray-900">
<div id="admin-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Admin Authentication Required</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4">This operation requires admin privileges. Please enter your admin token:</p>
<input
type="password"
id="admin-token-input"
placeholder="Enter admin token..."
class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white mb-4 focus:ring-2 focus:ring-blue-500 outline-none">
<div class="flex gap-2 justify-end">
<button
onclick="cancelAdminModal()"
class="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition">
Cancel
</button>
<button
onclick="submitAdminToken()"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition">
Submit
</button>
</div>
</div>
</div>
<div class="sticky-header w-full bg-white/90 dark:bg-gray-800/90 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-6xl mx-auto px-4 py-4 flex justify-between items-center">
<div class="flex items-center gap-4">
<a href="/" class="text-4xl font-bold text-blue-600 hover:text-blue-700 dark:text-blue-400 transition-colors">
Webhook Catcher 🚀
</a>
<a href="/" class="px-3 py-1.5 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors">
← Back
</a>
<span class="px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
📋 Logs
</span>
<span id="admin-status" class="px-2 py-1 text-xs rounded hidden">
🔒 Admin Protected
</span>
</div>
<button onclick="toggleDarkMode()" class="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2">
<span class="dark:hidden">🌙</span>
<span class="hidden dark:inline">☀️</span>
<span class="dark:hidden">Dark</span>
<span class="hidden dark:inline">Light</span>
</button>
</div>
</div>
<div class="max-w-6xl mx-auto p-8">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8 w-full">
<div class="flex flex-col lg:flex-row justify-between gap-4">
<div class="flex-1">
<input
type="search"
name="search"
placeholder="🔍 Search webhooks..."
class="w-full px-4 py-3 text-lg border rounded-lg bg-white dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 outline-none"
hx-get="/logs"
hx-trigger="keyup changed delay:500ms"
hx-target="#webhook-list">
</div>
<div class="flex flex-wrap gap-2">
<a href="/export?format=json"
class="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors inline-flex items-center gap-2">
<span>Export JSON</span>
<span>📥</span>
</a>
<a href="/export?format=csv"
class="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors inline-flex items-center gap-2">
<span>Export CSV</span>
<span>📥</span>
</a>
<button
onclick="clearLogsWithModal()"
class="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors inline-flex items-center gap-2">
<span>Clear All</span>
<span>🗑️</span>
</button>
</div>
</div>
</div>
<div id="webhook-list"
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden w-full"
hx-get="/logs"
hx-trigger="load, every 5s">
<div class="p-12 text-center">
<div class="animate-spin h-10 w-10 mx-auto mb-4">
<svg class="text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<p class="text-gray-500 dark:text-gray-400 text-lg">Loading webhooks...</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
dayjs.extend(window.dayjs_plugin_relativeTime);
});
function toggleDarkMode() {
const html = document.documentElement;
html.classList.toggle('dark');
document.body.classList.toggle('dark');
localStorage.setItem('darkMode', html.classList.contains('dark'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
}
document.addEventListener('htmx:afterSwap', function(evt) {
if (!evt.target) return;
evt.target.querySelectorAll('[data-timestamp]').forEach(el => {
el.textContent = dayjs(el.dataset.timestamp).fromNow();
});
evt.target.querySelectorAll('pre code').forEach(block => {
Prism.highlightElement(block);
});
});
async function executeAdminOperation(operation, operationName) {
try {
let result = await operation();
return result;
} catch (error) {
console.log(`Operation ${operationName} failed:`, error);
const is401 = error.status === 401 ||
(error.response && error.response.status === 401) ||
(error.message && error.message.includes('401')) ||
(error.message && error.message.includes('Admin token required'));
if (is401) {
console.log('401 detected, showing admin modal');
return new Promise((resolve, reject) => {
pendingAdminOperation = async () => {
try {
console.log('Retrying operation with token:', currentAdminToken ? 'provided' : 'missing');
const result = await operation(currentAdminToken);
resolve(result);
} catch (retryError) {
console.log('Retry failed:', retryError);
const isRetry401 = retryError.status === 401 ||
(retryError.response && retryError.response.status === 401) ||
(retryError.message && retryError.message.includes('401')) ||
(retryError.message && retryError.message.includes('Admin token required'));
if (isRetry401) {
alert('❌ Invalid admin token. Please try again.');
currentAdminToken = null;
showAdminModal(pendingAdminOperation);
} else {
alert(`❌ ${operationName} failed: ${retryError.message}`);
reject(retryError);
}
}
};
showAdminModal(pendingAdminOperation);
});
} else {
throw error;
}
}
}
window.replayWebhook = async function(btn) {
try {
const webhookItem = btn.closest('.webhook-item');
if (!webhookItem) {
console.error('Could not find webhook item');
return;
}
const webhookId = webhookItem.dataset.id;
const targetUrl = btn.previousElementSibling.value;
if (!webhookId) {
console.error('No webhook ID found');
return;
}
if (!targetUrl) {
alert('Please enter a target URL');
return;
}
console.log(`Attempting to replay webhook ${webhookId} to ${targetUrl}`);
btn.innerHTML = '⏳ Sending...';
btn.disabled = true;
await executeAdminOperation(async (token) => {
console.log('Executing replay operation, token provided:', !!token);
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['X-Admin-Token'] = token;
console.log('Added admin token to headers');
}
const response = await fetch(`/replay/${webhookId}?target_url=${encodeURIComponent(targetUrl)}`, {
method: 'POST',
headers: headers
});
console.log('Replay response status:', response.status);
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.detail || errorMessage;
} catch (e) {
errorMessage = response.statusText || errorMessage;
}
const err = new Error(errorMessage);
err.status = response.status;
err.response = response;
console.log('Throwing error with status:', err.status);
throw err;
}
return await response.json();
}, 'Replay webhook');
console.log('Replay successful');
btn.innerHTML = '✅ Sent!';
btn.classList.remove('bg-blue-500', 'bg-red-500');
btn.classList.add('bg-green-500');
} catch (error) {
console.error('Final replay error:', error);
if (!error.status || error.status !== 401) {
btn.innerHTML = '❌ Failed!';
btn.classList.remove('bg-blue-500', 'bg-green-500');
btn.classList.add('bg-red-500');
alert(`Replay failed: ${error.message}`);
}
}
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '🔄 Replay';
btn.classList.remove('bg-green-500', 'bg-red-500');
btn.classList.add('bg-blue-500', 'hover:bg-blue-600');
}, 2000);
};
let currentAdminToken = null;
let pendingAdminOperation = null;
function showAdminModal(operation) {
pendingAdminOperation = operation;
document.getElementById('admin-modal').classList.remove('hidden');
document.getElementById('admin-token-input').focus();
}
function cancelAdminModal() {
document.getElementById('admin-modal').classList.add('hidden');
document.getElementById('admin-token-input').value = '';
pendingAdminOperation = null;
currentAdminToken = null;
}
function submitAdminToken() {
const token = document.getElementById('admin-token-input').value.trim();
if (!token) {
alert('Please enter a token');
return;
}
currentAdminToken = token;
document.getElementById('admin-modal').classList.add('hidden');
document.getElementById('admin-token-input').value = '';
if (pendingAdminOperation) {
pendingAdminOperation();
pendingAdminOperation = null;
}
}
window.clearLogsWithModal = async function() {
const confirmed = confirm('Are you sure you want to clear all webhook logs?\n\nThis action cannot be undone and will remove all stored webhooks.');
if (!confirmed) return;
try {
console.log('Attempting to clear logs');
const response = await fetch('/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
console.log('Clear response status:', response.status);
if (response.status === 401) {
console.log('Got 401, showing admin modal for clear');
showAdminModal(async () => {
console.log('Retrying clear with admin token');
try {
const retryResponse = await fetch('/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': currentAdminToken
}
});
if (retryResponse.ok) {
alert('✅ Logs cleared successfully!');
window.location.reload();
} else {
alert('❌ Clear failed - invalid admin token');
}
} catch (retryError) {
alert('❌ Clear failed: ' + retryError.message);
}
});
return;
}
if (response.ok) {
alert('✅ Logs cleared successfully!');
window.location.reload();
} else {
alert('❌ Clear failed: HTTP ' + response.status);
}
} catch (error) {
console.error('Clear error:', error);
alert('❌ Network error: ' + error.message);
}
};
document.addEventListener('DOMContentLoaded', async function() {
try {
const response = await fetch('/config');
const config = await response.json();
const adminStatus = document.getElementById('admin-status');
if (config.admin_protection_enabled) {
adminStatus.classList.remove('hidden');
adminStatus.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
}
} catch (error) {
console.error('Failed to load config:', error);
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !document.getElementById('admin-modal').classList.contains('hidden')) {
cancelAdminModal();
}
if (e.key === 'Enter' && !document.getElementById('admin-modal').classList.contains('hidden')) {
submitAdminToken();
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,91 @@
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-4">
<div class="p-3 bg-blue-50 dark:bg-blue-900/50 rounded-lg">
<span class="text-3xl">📋</span>
</div>
<div>
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">Webhook Logs</h2>
<p class="text-sm text-gray-600 dark:text-gray-300 mt-1">
{{ total_count }} webhook{{ 's' if total_count != 1 else '' }} received
</p>
</div>
</div>
</div>
{% if total_count > 0 and logs %}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for log in logs %}
<div class="webhook-item p-6 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all" data-id="{{ log.id }}">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center gap-2">
<span class="text-sm font-mono px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-lg">
#{{ log.id }}
</span>
<span class="text-xs px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-white">
{{ log.metadata.ip }}
</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-300 font-mono relative-time"
data-timestamp="{{ log.timestamp.iso }}"
title="{{ log.timestamp.display }}">
{{ log.timestamp.display }}
</span>
</div>
{% if log.parsed_body %}
<pre class="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg shadow-inner overflow-x-auto">
<code class="language-json text-sm text-gray-800 dark:text-white">{{ log.parsed_body | tojson(indent=2) }}</code>
</pre>
{% else %}
<pre class="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg shadow-inner overflow-x-auto">
<code class="text-sm text-gray-800 dark:text-white">{{ log.body }}</code>
</pre>
{% endif %}
{% if log.matches %}
<div class="mt-2 space-y-1">
<p class="text-sm text-gray-500 dark:text-gray-300">Search matches:</p>
{% for match in log.matches %}
<div class="text-sm bg-yellow-50 dark:bg-yellow-900/30 p-2 rounded">
<span class="font-medium text-yellow-800 dark:text-yellow-200">{{ match.term }}:</span>
<span class="text-gray-700 dark:text-white">{{ match.context }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<div class="mt-4 flex gap-2">
<input type="url"
placeholder="Target URL"
class="flex-1 px-3 py-2 text-sm border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none"
value="{{ request.base_url }}webhook">
<button onclick="replayWebhook(this)"
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors">
🔄 Replay
</button>
</div>
</div>
{% endfor %}
</div>
{% if has_more %}
<div class="p-6 text-center border-t border-gray-200 dark:border-gray-700">
<button
class="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors inline-flex items-center gap-2"
hx-get="/logs?offset={{ offset + limit }}"
hx-target="#webhook-list"
hx-swap="beforeend">
<span>Load More</span>
<span></span>
</button>
</div>
{% endif %}
{% else %}
<div class="py-16 px-8 text-center">
<div class="max-w-sm mx-auto">
<div class="text-7xl mb-6">📭</div>
<h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">No webhooks yet</h3>
<p class="text-lg text-gray-600 dark:text-gray-300">Send a test webhook to get started!</p>
</div>
</div>
{% endif %}

BIN
assets/logs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
assets/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

17
env.example Normal file
View File

@ -0,0 +1,17 @@
# ================================
# Webhook Catcher Configuration
# ================================
# Optional: Forward incoming webhooks to another service
FORWARD_WEBHOOK_URL=
# Optional: Authentication token for forwarded webhooks
FORWARD_WEBHOOK_TOKEN=
# Optional: Admin token for sensitive operations (replay, clear logs)
# Leave empty to disable admin protection (development mode)
ADMIN_TOKEN=
# Optional: Protect the web UI with HTTP Basic Auth
# Leave empty to make UI public
FRONTEND_PASSWORD=

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
fastapi==0.128.0
uvicorn==0.40.0
jinja2==3.1.6
httpx==0.28.1
python-multipart==0.0.21
aiofiles==25.1.0