mirror of
https://github.com/LukeHagar/relay.git
synced 2025-12-09 20:57:45 +00:00
Checkpoint before follow-up message
Co-authored-by: lukeslakemail <lukeslakemail@gmail.com>
This commit is contained in:
@@ -43,6 +43,9 @@ export async function relayWebhookToTargets(
|
||||
'User-Agent': 'WebhookRelay/1.0',
|
||||
'X-Webhook-Relay-Source': 'webhook-relay',
|
||||
'X-Webhook-Relay-Target': target.nickname || target.id,
|
||||
'X-Webhook-Relay-Original-Path': webhookData.path,
|
||||
'X-Webhook-Relay-Original-Method': webhookData.method,
|
||||
'X-Webhook-Relay-Original-Query': webhookData.query,
|
||||
...webhookData.headers
|
||||
};
|
||||
|
||||
@@ -50,6 +53,8 @@ export async function relayWebhookToTargets(
|
||||
delete forwardHeaders['host'];
|
||||
delete forwardHeaders['authorization'];
|
||||
delete forwardHeaders['cookie'];
|
||||
delete forwardHeaders['x-webhook-relay-subdomain'];
|
||||
delete forwardHeaders['x-webhook-relay-user-id'];
|
||||
|
||||
// Forward the webhook
|
||||
const response = await fetch(target.target, {
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
activeConnections: 0,
|
||||
successRate: 0
|
||||
};
|
||||
|
||||
let pathStats = {
|
||||
topPaths: [],
|
||||
methodDistribution: [],
|
||||
recentPaths: []
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (data.session?.user) {
|
||||
@@ -42,9 +48,16 @@
|
||||
if (!data.session?.user) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/webhooks/stats');
|
||||
if (response.ok) {
|
||||
stats = await response.json();
|
||||
// Load basic stats
|
||||
const statsResponse = await fetch('/api/webhooks/stats');
|
||||
if (statsResponse.ok) {
|
||||
stats = await statsResponse.json();
|
||||
}
|
||||
|
||||
// Load path statistics
|
||||
const pathResponse = await fetch('/api/webhooks/paths?range=24h');
|
||||
if (pathResponse.ok) {
|
||||
pathStats = await pathResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
@@ -188,12 +201,17 @@
|
||||
{#each events.slice(0, 10) as event}
|
||||
<div class="webhook-event">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getMethodColor(event.method)}">
|
||||
{event.method}
|
||||
</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getMethodColor(event.method)}">
|
||||
{event.method}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-gray-900">{event.path}</span>
|
||||
{#if event.query}
|
||||
<span class="text-xs text-gray-500">?{event.query}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{formatDate(event.createdAt)}</span>
|
||||
</div>
|
||||
{#if event.body && event.body !== 'null'}
|
||||
@@ -210,6 +228,53 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Path Analytics -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Top Webhook Paths (24h)</h3>
|
||||
{#if pathStats.topPaths && pathStats.topPaths.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each pathStats.topPaths.slice(0, 5) as pathStat}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{pathStat.path}</div>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{pathStat.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500">No webhook paths yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">HTTP Method Distribution</h3>
|
||||
{#if pathStats.methodDistribution && pathStats.methodDistribution.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each pathStats.methodDistribution as methodStat}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getMethodColor(methodStat.method)}">
|
||||
{methodStat.method}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<span class="text-sm font-medium text-gray-900">{methodStat.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500">No method data yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="card">
|
||||
|
||||
@@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { subdomain, method = 'POST', path = '/test', body = { test: true } } = await request.json();
|
||||
const { subdomain, method = 'POST', path = '/test', body = { test: true }, headers = {} } = await request.json();
|
||||
|
||||
if (!subdomain) {
|
||||
return json({ error: 'Subdomain is required' }, { status: 400 });
|
||||
@@ -38,7 +38,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Test-Webhook/1.0',
|
||||
'X-Test-Webhook': 'true'
|
||||
'X-Test-Webhook': 'true',
|
||||
'X-Webhook-Path': path,
|
||||
...headers
|
||||
}),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
106
sveltekit-app/src/routes/api/webhooks/paths/+server.ts
Normal file
106
sveltekit-app/src/routes/api/webhooks/paths/+server.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const session = await locals.getSession();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const timeRange = url.searchParams.get('range') || '24h';
|
||||
let timeFilter = {};
|
||||
|
||||
// Calculate time range
|
||||
const now = new Date();
|
||||
switch (timeRange) {
|
||||
case '1h':
|
||||
timeFilter = { gte: new Date(now.getTime() - 60 * 60 * 1000) };
|
||||
break;
|
||||
case '24h':
|
||||
timeFilter = { gte: new Date(now.getTime() - 24 * 60 * 60 * 1000) };
|
||||
break;
|
||||
case '7d':
|
||||
timeFilter = { gte: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) };
|
||||
break;
|
||||
case '30d':
|
||||
timeFilter = { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) };
|
||||
break;
|
||||
default:
|
||||
timeFilter = { gte: new Date(now.getTime() - 24 * 60 * 60 * 1000) };
|
||||
}
|
||||
|
||||
// Get path statistics
|
||||
const pathStats = await prisma.webhookEvent.groupBy({
|
||||
by: ['path'],
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
createdAt: timeFilter
|
||||
},
|
||||
_count: {
|
||||
path: true
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
path: 'desc'
|
||||
}
|
||||
},
|
||||
take: 10
|
||||
});
|
||||
|
||||
// Get method distribution
|
||||
const methodStats = await prisma.webhookEvent.groupBy({
|
||||
by: ['method'],
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
createdAt: timeFilter
|
||||
},
|
||||
_count: {
|
||||
method: true
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
method: 'desc'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent paths (last 10 unique paths)
|
||||
const recentPaths = await prisma.webhookEvent.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
createdAt: timeFilter
|
||||
},
|
||||
select: {
|
||||
path: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 100
|
||||
});
|
||||
|
||||
// Get unique paths from recent events
|
||||
const uniqueRecentPaths = [...new Set(recentPaths.map(e => e.path))].slice(0, 10);
|
||||
|
||||
return json({
|
||||
pathStats: pathStats.map(stat => ({
|
||||
path: stat.path,
|
||||
count: stat._count.path
|
||||
})),
|
||||
methodStats: methodStats.map(stat => ({
|
||||
method: stat.method,
|
||||
count: stat._count.method
|
||||
})),
|
||||
recentPaths: uniqueRecentPaths,
|
||||
timeRange
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching webhook path stats:', error);
|
||||
return json({ error: 'Failed to fetch path statistics' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -41,6 +41,15 @@ async function handleWebhook(request: Request, params: any, url: URL) {
|
||||
return json({ error: 'Missing Subdomain' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract and validate webhook path
|
||||
const webhookPath = params.path || '';
|
||||
const fullPath = url.pathname;
|
||||
|
||||
// Validate path format (optional: add path restrictions)
|
||||
if (webhookPath.length > 500) {
|
||||
return json({ error: 'Webhook path too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find user by subdomain
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { subdomain },
|
||||
@@ -72,19 +81,29 @@ async function handleWebhook(request: Request, params: any, url: URL) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers (excluding sensitive ones)
|
||||
// Get and process headers (excluding sensitive ones)
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of request.headers.entries()) {
|
||||
if (!['authorization', 'cookie', 'x-forwarded-for'].includes(key.toLowerCase())) {
|
||||
const headerEntries = request.headers.entries();
|
||||
|
||||
for (const [key, value] of headerEntries) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
// Exclude sensitive headers and add webhook-specific headers
|
||||
if (!['authorization', 'cookie', 'x-forwarded-for', 'x-real-ip'].includes(lowerKey)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Build webhook event
|
||||
// Add webhook-specific headers for tracking
|
||||
headers['X-Webhook-Relay-Subdomain'] = subdomain;
|
||||
headers['X-Webhook-Relay-Path'] = webhookPath;
|
||||
headers['X-Webhook-Relay-Timestamp'] = new Date().toISOString();
|
||||
headers['X-Webhook-Relay-User-Id'] = user.id;
|
||||
|
||||
// Build webhook event with enhanced path handling
|
||||
const webhookEvent = {
|
||||
userId: user.id,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
path: webhookPath, // Store the actual webhook path, not full URL path
|
||||
query: url.search,
|
||||
body: JSON.stringify(body),
|
||||
headers: JSON.stringify(headers),
|
||||
@@ -98,9 +117,9 @@ async function handleWebhook(request: Request, params: any, url: URL) {
|
||||
|
||||
// Relay to configured targets (async, don't wait for completion)
|
||||
const relayResults = await relayWebhookToTargets(user.id, {
|
||||
method: webhookData.method,
|
||||
path: webhookData.path,
|
||||
query: webhookData.query,
|
||||
method: request.method,
|
||||
path: webhookPath, // Use the actual webhook path
|
||||
query: url.search,
|
||||
body: body,
|
||||
headers: headers
|
||||
});
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
let events = data.events;
|
||||
let searchTerm = '';
|
||||
let selectedMethod = 'all';
|
||||
let selectedPath = 'all';
|
||||
let showFilters = false;
|
||||
let currentPage = data.page;
|
||||
let totalPages = data.totalPages;
|
||||
|
||||
const methods = ['all', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
||||
|
||||
// Extract unique paths from events for filtering
|
||||
$: uniquePaths = [...new Set(events.map(e => e.path))].sort();
|
||||
|
||||
onMount(() => {
|
||||
if (data.session?.user) {
|
||||
@@ -39,7 +43,8 @@
|
||||
const matchesSearch = event.path.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.body.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesMethod = selectedMethod === 'all' || event.method === selectedMethod;
|
||||
return matchesSearch && matchesMethod;
|
||||
const matchesPath = selectedPath === 'all' || event.path === selectedPath;
|
||||
return matchesSearch && matchesMethod && matchesPath;
|
||||
});
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
@@ -117,7 +122,7 @@
|
||||
<!-- Filters -->
|
||||
{#if showFilters}
|
||||
<div class="card">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Search
|
||||
@@ -147,6 +152,21 @@
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="path" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Webhook Path
|
||||
</label>
|
||||
<select
|
||||
id="path"
|
||||
bind:value={selectedPath}
|
||||
class="input-field"
|
||||
>
|
||||
<option value="all">All Paths</option>
|
||||
{#each uniquePaths as path}
|
||||
<option value={path}>{path}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -177,17 +197,19 @@
|
||||
<div class="space-y-4">
|
||||
{#each filteredEvents as event}
|
||||
<div class="webhook-event">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getMethodColor(event.method)}">
|
||||
{event.method}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900">{event.path}</span>
|
||||
{#if event.query}
|
||||
<span class="text-sm text-gray-500">?{event.query}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getMethodColor(event.method)}">
|
||||
{event.method}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-gray-900">{event.path}</span>
|
||||
{#if event.query}
|
||||
<span class="text-xs text-gray-500">?{event.query}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 mb-2">
|
||||
{formatDate(event.createdAt)}
|
||||
|
||||
Reference in New Issue
Block a user