Initial commit
This commit is contained in:
commit
f3d5de9f07
|
|
@ -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
|
||||
|
|
@ -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 🚀
|
||||
|
|
@ -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}"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,253 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 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:**
|
||||
[](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
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🔧 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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 %}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue