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