mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
241 lines
7.1 KiB
TypeScript
241 lines
7.1 KiB
TypeScript
import { Prisma, Webhook } from "@prisma/client";
|
|
import { v7 as uuidv7 } from "uuid";
|
|
import { prisma } from "@formbricks/database";
|
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
|
import { ZId } from "@formbricks/types/common";
|
|
import {
|
|
DatabaseError,
|
|
InvalidInputError,
|
|
ResourceNotFoundError,
|
|
UnknownError,
|
|
} from "@formbricks/types/errors";
|
|
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
|
|
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
|
import { validateInputs } from "@/lib/utils/validate";
|
|
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
|
import { getTranslate } from "@/lingodotdev/server";
|
|
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
|
import { TWebhookInput } from "../types/webhooks";
|
|
|
|
const getWebhookTestErrorMessage = async (statusCode: number): Promise<string | null> => {
|
|
switch (statusCode) {
|
|
case 500: {
|
|
const t = await getTranslate();
|
|
return t("environments.integrations.webhooks.endpoint_internal_server_error");
|
|
}
|
|
case 404: {
|
|
const t = await getTranslate();
|
|
return t("environments.integrations.webhooks.endpoint_not_found_error");
|
|
}
|
|
case 405: {
|
|
const t = await getTranslate();
|
|
return t("environments.integrations.webhooks.endpoint_method_not_allowed_error");
|
|
}
|
|
case 502: {
|
|
const t = await getTranslate();
|
|
return t("environments.integrations.webhooks.endpoint_bad_gateway_error");
|
|
}
|
|
case 503: {
|
|
const t = await getTranslate();
|
|
return t("environments.integrations.webhooks.endpoint_service_unavailable_error");
|
|
}
|
|
case 504: {
|
|
const t = await getTranslate();
|
|
return t("environments.integrations.webhooks.endpoint_gateway_timeout_error");
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const updateWebhook = async (
|
|
webhookId: string,
|
|
webhookInput: Partial<TWebhookInput>
|
|
): Promise<boolean> => {
|
|
if (webhookInput.url) {
|
|
await validateWebhookUrl(webhookInput.url);
|
|
}
|
|
|
|
try {
|
|
await prisma.webhook.update({
|
|
where: {
|
|
id: webhookId,
|
|
},
|
|
data: {
|
|
name: webhookInput.name,
|
|
url: webhookInput.url,
|
|
triggers: webhookInput.triggers,
|
|
surveyIds: webhookInput.surveyIds || [],
|
|
},
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
throw new DatabaseError(error.message);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const deleteWebhook = async (id: string): Promise<boolean> => {
|
|
try {
|
|
await prisma.webhook.delete({
|
|
where: {
|
|
id,
|
|
},
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
|
) {
|
|
throw new ResourceNotFoundError("Webhook", id);
|
|
}
|
|
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
|
|
}
|
|
};
|
|
|
|
export const createWebhook = async (
|
|
environmentId: string,
|
|
webhookInput: TWebhookInput,
|
|
secret?: string
|
|
): Promise<Webhook> => {
|
|
await validateWebhookUrl(webhookInput.url);
|
|
|
|
try {
|
|
if (isDiscordWebhook(webhookInput.url)) {
|
|
throw new UnknownError("Discord webhooks are currently not supported.");
|
|
}
|
|
|
|
const signingSecret = secret ?? generateWebhookSecret();
|
|
|
|
const webhook = await prisma.webhook.create({
|
|
data: {
|
|
...webhookInput,
|
|
surveyIds: webhookInput.surveyIds || [],
|
|
secret: signingSecret,
|
|
environment: {
|
|
connect: {
|
|
id: environmentId,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return webhook;
|
|
} catch (error) {
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
throw new DatabaseError(error.message);
|
|
}
|
|
|
|
if (!(error instanceof InvalidInputError)) {
|
|
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const getWebhooks = async (environmentId: string): Promise<Webhook[]> => {
|
|
validateInputs([environmentId, ZId]);
|
|
|
|
try {
|
|
const webhooks = await prisma.webhook.findMany({
|
|
where: {
|
|
environmentId: environmentId,
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
});
|
|
return webhooks;
|
|
} catch (error) {
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
throw new DatabaseError(error.message);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
|
|
await validateWebhookUrl(url);
|
|
|
|
if (isDiscordWebhook(url)) {
|
|
throw new UnknownError("Discord webhooks are currently not supported.");
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
|
|
try {
|
|
const webhookMessageId = uuidv7();
|
|
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
|
const body = JSON.stringify({ event: "testEndpoint" });
|
|
|
|
const requestHeaders: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
"webhook-id": webhookMessageId,
|
|
"webhook-timestamp": webhookTimestamp.toString(),
|
|
};
|
|
|
|
if (secret) {
|
|
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
|
webhookMessageId,
|
|
webhookTimestamp,
|
|
body,
|
|
secret
|
|
);
|
|
}
|
|
|
|
// `redirect: "manual"` prevents SSRF via redirect — validateWebhookUrl only checks the
|
|
// initial URL, so following 30x to a private/internal host (e.g. cloud metadata) would bypass it.
|
|
// Gated on the same env var as validateWebhookUrl: self-hosters who opted into trusting internal
|
|
// URLs also get the pre-patch redirect-follow behavior for consistency.
|
|
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
body,
|
|
headers: requestHeaders,
|
|
signal: controller.signal,
|
|
redirect: redirectMode,
|
|
});
|
|
|
|
const statusCode = response.status;
|
|
|
|
// With `redirect: "manual"`, Node's undici returns the actual 30x response (not the spec's
|
|
// opaqueredirect filter). Treat any 30x as a redirect rejection so users get a clear error
|
|
// instead of a misleading success. With `redirect: "follow"`, fetch returns the final 2xx/4xx/5xx
|
|
// and this branch is unreachable.
|
|
if (statusCode >= 300 && statusCode < 400) {
|
|
throw new InvalidInputError("Webhook endpoint returned a redirect, which is not allowed");
|
|
}
|
|
|
|
const errorMessage = await getWebhookTestErrorMessage(statusCode);
|
|
|
|
if (errorMessage) {
|
|
throw new InvalidInputError(errorMessage);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
throw new UnknownError("Request timed out after 5 seconds");
|
|
}
|
|
|
|
if (error instanceof InvalidInputError || error instanceof UnknownError) {
|
|
throw error;
|
|
}
|
|
|
|
throw new UnknownError(
|
|
`Error while fetching the URL: ${error instanceof Error ? error.message : "Unknown error occurred"}`
|
|
);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|