Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/db/entities/entityWebhooks.db.server.ts
T

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