Checkpoint before follow-up message

Co-authored-by: lukeslakemail <lukeslakemail@gmail.com>
This commit is contained in:
Cursor Agent
2025-08-30 03:53:36 +00:00
parent b407466256
commit b84ce4c8c5
6 changed files with 249 additions and 30 deletions

View File

@@ -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, {

View File

@@ -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">

View File

@@ -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(),
};

View 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 });
}
};

View File

@@ -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
});

View File

@@ -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)}