import { EntityWebhook } from "@toolctrl/db/prisma-browser"; import { db } from "~/utils/db.server"; import { getBaseURL } from "~/utils/url.server"; import { fetchOutboundHttpWithRedirects } from "~/utils/outboundHttp.server"; const BLOCKED_WEBHOOK_STATUS = 0; export type EntityWebhookWithDetails = EntityWebhook & { _count: { logs: number } }; export async function getEntityWebhooks(entityId: string) { return await db.entityWebhook.findMany({ where: { entityId, }, include: { _count: { select: { logs: true, }, }, }, }); } export async function getEntityWebhook(input: { id: string; entityId: string }) { return await db.entityWebhook.findFirst({ where: { id: input.id, entityId: input.entityId, }, }); } export async function getEntityWebhooksByAction(entityId: string, action: string) { return await db.entityWebhook.findMany({ where: { entityId, action, }, }); } export async function createEntityWebhook(data: { entityId: string; action: string; method: string; endpoint: string }) { return await db.entityWebhook.create({ data, }); } export async function updateEntityWebhook( input: { id: string; entityId: string }, data: { action: string; method: string; endpoint: string; } ) { const result = await db.entityWebhook.updateMany({ where: { id: input.id, entityId: input.entityId, }, data, }); if (result.count !== 1) { throw new Error("Entity webhook not found."); } return result; } export async function deleteEntityWebhook(input: { id: string; entityId: string }) { const result = await db.entityWebhook.deleteMany({ where: { id: input.id, entityId: input.entityId, }, }); if (result.count !== 1) { throw new Error("Entity webhook not found."); } return result; } export async function callEntityWebhooks({ logId, entityId, action, body, request, }: { logId: string; entityId: string; action: string; body: string; request?: Request; }) { const webhooks = await getEntityWebhooksByAction(entityId, action); return await Promise.all( webhooks.map(async (webhook) => { if (webhook.endpoint) { const status = await fetchLegacyWebhookStatus(webhook.endpoint, request, { method: webhook.method, body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }); return db.entityWebhookLog.create({ data: { webhookId: webhook.id, logId, status, }, }); } }) ); } function resolveWebhookEndpoint(endpoint: string, request?: Request): string { return endpoint.startsWith("/") && request ? getBaseURL(request) + endpoint : endpoint; } // Delivery goes through the shared SSRF-safe outbound client, which validates // the scheme, blocks literal private/loopback/link-local/metadata hosts, AND // resolves DNS to reject public hostnames that resolve to internal addresses // (DNS rebinding) — the gap in the previous forked, hostname-string-only guard. // Redirect hops are re-validated and resolved the same way. Any rejection or // transport error is recorded as a blocked-delivery status. async function fetchLegacyWebhookStatus(endpoint: string, request: Request | undefined, init: RequestInit): Promise { try { const response = await fetchOutboundHttpWithRedirects(resolveWebhookEndpoint(endpoint, request), init); return response.status; } catch { return BLOCKED_WEBHOOK_STATUS; } }