134 lines
3.5 KiB
TypeScript
134 lines
3.5 KiB
TypeScript
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<number> {
|
|
try {
|
|
const response = await fetchOutboundHttpWithRedirects(resolveWebhookEndpoint(endpoint, request), init);
|
|
return response.status;
|
|
} catch {
|
|
return BLOCKED_WEBHOOK_STATUS;
|
|
}
|
|
}
|