refactor: Use Iffy for workflow body scanning (#21170)

* Add Iffy API key variable

* WIP using Iffy to scan comments

* Use Iffy for workflow body scanning

* Update entity

* Clean up

* Update test

* Fix client id

* Fix test
This commit is contained in:
Joe Au-Yeung
2025-05-08 23:38:49 +01:00
committed by GitHub
parent dddbd7dad4
commit 50173658f8
6 changed files with 88 additions and 45 deletions
+1 -1
View File
@@ -425,7 +425,7 @@ DIRECTORY_IDS_TO_LOG=
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
AKISMET_API_KEY=
IFFY_API_KEY=
## Env variables related to avoiding booking failures
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
@@ -1,4 +1,5 @@
"use client"
"use client";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -7,21 +7,6 @@ import { scheduleWorkflowNotifications } from "@calcom/trpc/server/routers/viewe
import { scanWorkflowBody } from "./scanWorkflowBody";
const mockAkismetCheckSpam = vi.fn();
// Mock the entire module
vi.mock("akismet-api", () => {
return {
AkismetClient: class {
constructor() {
return {
checkSpam: mockAkismetCheckSpam,
};
}
},
};
});
vi.mock("@calcom/lib/autoLock", async (importActual) => {
const actual = await importActual<typeof import("@calcom/lib/autoLock")>();
return {
@@ -54,15 +39,18 @@ const mockWorkflow = {
};
describe("scanWorkflowBody", () => {
const mockFetch = vi.fn();
beforeEach(() => {
vi.resetAllMocks();
process.env.AKISMET_API_KEY = "test-key";
vi.stubGlobal("fetch", mockFetch);
process.env.IFFY_API_KEY = "test-key";
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
});
it("should skip scan if AKISMET_API_KEY is not set", async () => {
process.env.AKISMET_API_KEY = "";
it("should skip scan if IFFY_API_KEY is not set", async () => {
process.env.IFFY_API_KEY = "";
const payload = JSON.stringify({
userId: 1,
workflowStepIds: [1],
@@ -98,13 +86,25 @@ describe("scanWorkflowBody", () => {
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
mockAkismetCheckSpam.mockResolvedValue(false);
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ flagged: false }),
});
await scanWorkflowBody(payload);
expect(mockAkismetCheckSpam).toHaveBeenCalledWith({
user_ip: "127.0.0.1",
content: mockWorkflowStep.reminderBody,
expect(mockFetch).toHaveBeenCalledWith("https://api.iffy.com/api/v1/moderate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer test-key`,
},
body: JSON.stringify({
clientId: "Workflow step - 1",
name: "Workflow",
entity: "WorkflowBody",
content: "Test reminder body",
passthrough: true,
}),
});
expect(prismaMock.workflowStep.update).toHaveBeenCalledWith({
where: { id: 1 },
@@ -119,11 +119,26 @@ describe("scanWorkflowBody", () => {
});
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
mockAkismetCheckSpam.mockResolvedValue(true);
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ flagged: true }),
});
await scanWorkflowBody(payload);
expect(mockAkismetCheckSpam).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith("https://api.iffy.com/api/v1/moderate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer test-key`,
},
body: JSON.stringify({
clientId: "Workflow step - 1",
name: "Workflow",
entity: "WorkflowBody",
content: "Test reminder body",
passthrough: true,
}),
});
expect(prismaMock.workflowStep.update).not.toHaveBeenCalled();
expect(lockUser).toHaveBeenCalledWith("userId", "1", LockReason.SPAM_WORKFLOW_BODY);
});
@@ -136,7 +151,6 @@ describe("scanWorkflowBody", () => {
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
mockAkismetCheckSpam.mockResolvedValue(false);
await scanWorkflowBody(payload);
@@ -166,7 +180,6 @@ describe("scanWorkflowBody", () => {
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
prismaMock.workflow.findFirst.mockResolvedValue(null);
mockAkismetCheckSpam.mockResolvedValue(false);
await scanWorkflowBody(payload);
@@ -183,11 +196,26 @@ describe("scanWorkflowBody", () => {
{ ...mockWorkflowStep, workflow: { user: { whitelistWorkflows: true } } },
]);
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
mockAkismetCheckSpam.mockResolvedValue(true);
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ flagged: true }),
});
await scanWorkflowBody(payload);
expect(mockAkismetCheckSpam).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith("https://api.iffy.com/api/v1/moderate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer test-key`,
},
body: JSON.stringify({
clientId: "Workflow step - 1",
name: "Workflow",
entity: "WorkflowBody",
content: "Test reminder body",
passthrough: true,
}),
});
expect(prismaMock.workflowStep.update).not.toHaveBeenCalled();
expect(lockUser).not.toHaveBeenCalled();
});
@@ -1,11 +1,8 @@
import { AkismetClient } from "akismet-api";
import type { Comment } from "akismet-api";
import z from "zod";
import { getTemplateBodyForAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import compareReminderBodyToTemplate from "@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate";
import { lockUser, LockReason } from "@calcom/lib/autoLock";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -20,8 +17,8 @@ export const scanWorkflowBodySchema = z.object({
const log = logger.getSubLogger({ prefix: ["[tasker] scanWorkflowBody"] });
export async function scanWorkflowBody(payload: string) {
if (!process.env.AKISMET_API_KEY) {
log.info("AKISMET_API_KEY not set, skipping scan");
if (!process.env.IFFY_API_KEY) {
log.info("IFFY_API_KEY not set, skipping scan");
return;
}
@@ -48,8 +45,6 @@ export async function scanWorkflowBody(payload: string) {
},
});
const client = new AkismetClient({ key: process.env.AKISMET_API_KEY, blog: WEBAPP_URL });
for (const workflowStep of workflowSteps) {
if (!workflowStep.reminderBody) {
await prisma.workflowStep.update({
@@ -93,12 +88,7 @@ export async function scanWorkflowBody(payload: string) {
continue;
}
const comment: Comment = {
user_ip: "127.0.0.1",
content: workflowStep.reminderBody,
};
const isSpam = await client.checkSpam(comment);
const isSpam = await iffyScanBody(workflowStep.reminderBody, workflowStep.id);
if (isSpam) {
if (workflowStep.workflow.user?.whitelistWorkflows) {
@@ -160,3 +150,27 @@ export async function scanWorkflowBody(payload: string) {
teamId: workflow.team?.id || null,
});
}
const iffyScanBody = async (body: string, workflowStepId: number) => {
try {
const response = await fetch("https://api.iffy.com/api/v1/moderate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IFFY_API_KEY}`,
},
body: JSON.stringify({
clientId: `Workflow step - ${workflowStepId}`,
name: "Workflow",
entity: "WorkflowBody",
content: body,
passthrough: true,
}),
});
const data = await response.json();
return data.flagged;
} catch (error) {
log.error(`Error scanning workflow body for workflow step ${workflowStepId}:`, error);
}
};
+1 -1
View File
@@ -208,7 +208,7 @@ export const GOOGLE_CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
];
export const DIRECTORY_IDS_TO_LOG = process.env.DIRECTORY_IDS_TO_LOG?.split(",") || [];
export const SCANNING_WORKFLOW_STEPS = !IS_SELF_HOSTED && process.env.AKISMET_API_KEY;
export const SCANNING_WORKFLOW_STEPS = !!(!IS_SELF_HOSTED && process.env.IFFY_API_KEY);
export const IS_PLAIN_CHAT_ENABLED =
!!process.env.NEXT_PUBLIC_PLAIN_CHAT_ID && process.env.NEXT_PUBLIC_PLAIN_CHAT_ID !== "";
+1 -1
View File
@@ -241,7 +241,6 @@
"globalEnv": [
"ALLOWED_HOSTNAMES",
"ANALYZE",
"AKISMET_API_KEY",
"API_KEY_PREFIX",
"APP_USER_NAME",
"BASECAMP3_CLIENT_ID",
@@ -315,6 +314,7 @@
"HEROKU_APP_NAME",
"HUBSPOT_CLIENT_ID",
"HUBSPOT_CLIENT_SECRET",
"IFFY_API_KEY",
"INTEGRATION_TEST_MODE",
"IS_E2E",
"INTERCOM_SECRET",