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

๐Ÿš€ Webhook Catcher Status

+
+
+
Webhook URL
+ +
+
+
Total Received
+ - +
+
+
+ +
+

๐Ÿ“ฅ Your Webhook URL

+
+ + +
+
+ +
+
+ + ๐Ÿ“š Quick Start Guide + +
+

Test with cURL

+ +
+
+

PowerShell/Terminal:

+
curl -X POST /webhook -H "Content-Type: application/json" -d "{\"event\": \"test\", \"message\": \"Hello World!\"}"
+ +
+
+ +

Common Use Cases

+
    +
  • GitHub repository events and CI/CD pipelines
  • +
  • Discord/Slack bot development and testing
  • +
  • Payment processing (Stripe, PayPal) notifications
  • +
  • IoT device updates and monitoring
  • +
+ +

How to Use

+
    +
  1. Copy your webhook URL from above
  2. +
  3. Add it to your service (GitHub, Discord, Stripe, etc.)
  4. +
  5. Send webhooks and view them in real-time below
  6. +
  7. Use search, replay, or export features as needed
  8. +
+
+
+
+ +
+

๐Ÿงช Test Your Setup

+
+ +
+ +
+ +
+
+

๐Ÿ“‹ Webhook Logs

+ +
+
+ + ๐Ÿ“ฅ JSON + + + ๐Ÿ“ฅ CSV + + +
+
+ +
Loading logs...
+ +
+
+
+ + + + +
+

Loading webhooks...

+
+
+
+ + + + diff --git a/app/templates/logs.html b/app/templates/logs.html new file mode 100644 index 0000000..4e29281 --- /dev/null +++ b/app/templates/logs.html @@ -0,0 +1,388 @@ + + + Webhook Logs - Webhook Catcher + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + Export JSON + ๐Ÿ“ฅ + + + Export CSV + ๐Ÿ“ฅ + + +
+
+
+ +
+
+
+ + + + +
+

Loading webhooks...

+
+
+
+ + + + + diff --git a/app/templates/logs_list.html b/app/templates/logs_list.html new file mode 100644 index 0000000..89f2be6 --- /dev/null +++ b/app/templates/logs_list.html @@ -0,0 +1,91 @@ +
+
+
+ ๐Ÿ“‹ +
+
+

Webhook Logs

+

+ {{ total_count }} webhook{{ 's' if total_count != 1 else '' }} received +

+
+
+
+ +{% if total_count > 0 and logs %} +
+ {% for log in logs %} +
+
+
+ + #{{ log.id }} + + + {{ log.metadata.ip }} + +
+ + {{ log.timestamp.display }} + +
+ + {% if log.parsed_body %} +
+                {{ log.parsed_body | tojson(indent=2) }}
+            
+ {% else %} +
+                {{ log.body }}
+            
+ {% endif %} + + {% if log.matches %} +
+

Search matches:

+ {% for match in log.matches %} +
+ {{ match.term }}: + {{ match.context }} +
+ {% endfor %} +
+ {% endif %} + +
+ + +
+
+ {% endfor %} +
+ + {% if has_more %} +
+ +
+ {% endif %} +{% else %} +
+
+
๐Ÿ“ญ
+

No webhooks yet

+

Send a test webhook to get started!

+
+
+{% endif %} diff --git a/assets/logs.png b/assets/logs.png new file mode 100644 index 0000000..85c2e1f Binary files /dev/null and b/assets/logs.png differ diff --git a/assets/main.png b/assets/main.png new file mode 100644 index 0000000..3588590 Binary files /dev/null and b/assets/main.png differ diff --git a/assets/webhookcatcher.png.png b/assets/webhookcatcher.png.png new file mode 100644 index 0000000..b23a7bb Binary files /dev/null and b/assets/webhookcatcher.png.png differ diff --git a/env.example b/env.example new file mode 100644 index 0000000..594eb7b --- /dev/null +++ b/env.example @@ -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= diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a9e5d78 --- /dev/null +++ b/requirements.txt @@ -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