Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,23 @@ Join our [community](https://evolutionfoundation.com.br/community) to discuss id

---

## MySQL compatibility in this fork

Set `DATABASE_PROVIDER=mysql` and a dedicated MySQL `DATABASE_CONNECTION_URI` before building.
`npm run build` generates the provider-specific Prisma client before type-checking and bundling.
Do not skip the TypeScript check or generate a PostgreSQL client for a MySQL deployment.

Before starting the updated API, back up an existing database and run `npm run db:deploy`
(`npm run db:deploy:win` on Windows). The added MySQL migration restores `IsOnWhatsapp.lid`
and adds the compound `Label(labelId, instanceId)` unique index. It does not delete duplicate
labels: if duplicates exist, resolve them deliberately before applying the migration. The existing
upstream migration history is unchanged; review pending migrations when upgrading an older database.

Compatibility tests use a disposable local MariaDB/MySQL database named
`evolution_mysql_test_<digits>` after applying the MySQL migrations:
`node --test test/mysql-compatibility.test.cjs`. They execute service methods without starting
WhatsApp or contacting external services. The live WhatsApp connection still needs a separate smoke test.

## Security

For security issues, **do not open a public issue**. Email **[email protected]** or use GitHub's private vulnerability reporting. See [SECURITY.md](./SECURITY.md) for details.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "./dist/main.js",
"type": "commonjs",
"scripts": {
"build": "tsc --noEmit && tsup",
"build": "npm run db:generate && tsc --noEmit && tsup",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Build provider enables shell commands

When an attacker can control DATABASE_PROVIDER in the build environment, runWithProvider.js substitutes DATABASE_PROVIDER into the command and passes it to execSync; shell metacharacters in a provider value execute attacker-chosen commands during the build, exposing or altering build-worker data.

Validate DATABASE_PROVIDER against the supported provider names and invoke Prisma without constructing a shell command from the value.

Prompt for AI agents
In `package.json` at line 8:

**Build provider enables shell commands**

When an attacker can control `DATABASE_PROVIDER` in the build environment, `runWithProvider.js` substitutes `DATABASE_PROVIDER` into the command and passes it to `execSync`; shell metacharacters in a provider value execute attacker-chosen commands during the build, exposing or altering build-worker data.

Validate `DATABASE_PROVIDER` against the supported provider names and invoke Prisma without constructing a shell command from the value.

"start": "tsx ./src/main.ts",
"start:prod": "node dist/main",
"dev:server": "tsx watch ./src/main.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Fail on existing duplicates rather than silently deleting or merging customer data.
CREATE UNIQUE INDEX `Label_labelId_instanceId_key` ON `Label`(`labelId`, `instanceId`);

-- Restore the field removed by the Kafka migration and used by the WhatsApp cache.
ALTER TABLE `IsOnWhatsapp` ADD COLUMN `lid` VARCHAR(100) NULL;
3 changes: 3 additions & 0 deletions prisma/mysql-schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ model Label {
updatedAt DateTime @updatedAt @db.Timestamp
Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
instanceId String

@@unique([labelId, instanceId])
}

model Proxy {
Expand Down Expand Up @@ -655,6 +657,7 @@ model IsOnWhatsapp {
id String @id @default(cuid())
remoteJid String @unique @db.VarChar(100)
jidOptions String
lid String? @db.VarChar(100)
createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP")) @db.Timestamp
updatedAt DateTime @updatedAt @db.Timestamp
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ export class BusinessStartupService extends ChannelStartupService {
where: {
instanceId: this.instanceId,
key: {
path: ['id'],
path: this.prismaRepository.jsonPath('id'),
equals: key.id,
},
},
Expand Down
133 changes: 88 additions & 45 deletions src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,12 +522,9 @@ export class BaileysStartupService extends ChannelStartupService {

private async getMessage(key: proto.IMessageKey, full = false) {
try {
// Use raw SQL to avoid JSON path issues
const webMessageInfo = (await this.prismaRepository.$queryRaw`
SELECT * FROM "Message"
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'id' = ${key.id}
`) as proto.IWebMessageInfo[];
const webMessageInfo = (await this.prismaRepository.message.findMany({
where: { instanceId: this.instanceId, key: { path: this.prismaRepository.jsonPath('id'), equals: key.id } },
})) as unknown as proto.IWebMessageInfo[];

if (full) {
return webMessageInfo[0];
Expand Down Expand Up @@ -1637,13 +1634,12 @@ export class BaileysStartupService extends ChannelStartupService {

const searchId = originalMessageId || key.id;

const messages = (await this.prismaRepository.$queryRaw`
SELECT * FROM "Message"
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'id' = ${searchId}
LIMIT 1
`) as any[];
findMessage = messages[0] || null;
findMessage = await this.prismaRepository.message.findFirst({
where: {
instanceId: this.instanceId,
key: { path: this.prismaRepository.jsonPath('id'), equals: searchId },
},
});

if (!findMessage?.id) {
this.logger.warn(`Original message not found for update. Skipping. Key: ${JSON.stringify(key)}`);
Expand Down Expand Up @@ -3776,7 +3772,7 @@ export class BaileysStartupService extends ChannelStartupService {
if (messageId) {
const isLogicalDeleted = configService.get<Database>('DATABASE').DELETE_DATA.LOGICAL_MESSAGE_DELETE;
let message = await this.prismaRepository.message.findFirst({
where: { key: { path: ['id'], equals: messageId } },
where: { key: { path: this.prismaRepository.jsonPath('id'), equals: messageId } },
});
if (isLogicalDeleted) {
if (!message) return response;
Expand Down Expand Up @@ -4196,7 +4192,7 @@ export class BaileysStartupService extends ChannelStartupService {
const messageId = messageSent.message?.protocolMessage?.key?.id;
if (messageId && this.configService.get<Database>('DATABASE').SAVE_DATA.NEW_MESSAGE) {
let message = await this.prismaRepository.message.findFirst({
where: { key: { path: ['id'], equals: messageId } },
where: { key: { path: this.prismaRepository.jsonPath('id'), equals: messageId } },
});
if (!message) throw new NotFoundException('Message not found');

Expand Down Expand Up @@ -4734,20 +4730,22 @@ export class BaileysStartupService extends ChannelStartupService {
private async updateMessagesReadedByTimestamp(remoteJid: string, timestamp?: number): Promise<number> {
if (timestamp === undefined || timestamp === null) return 0;

// Use raw SQL to avoid JSON path issues
const result = await this.prismaRepository.$executeRaw`
UPDATE "Message"
SET "status" = ${status[4]}
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'remoteJid' = ${remoteJid}
AND ("key"->>'fromMe')::boolean = false
AND "messageTimestamp" <= ${timestamp}
AND ("status" IS NULL OR "status" = ${status[3]})
`;
const { count: result } = await this.prismaRepository.message.updateMany({
where: {
instanceId: this.instanceId,
AND: [
{ key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: remoteJid } },
{ key: { path: this.prismaRepository.jsonPath('fromMe'), equals: false } },
],
messageTimestamp: { lte: timestamp },
OR: [{ status: null }, { status: status[3] }],
},
data: { status: status[4] },
});

if (result) {
if (result > 0) {
this.updateChatUnreadMessages(remoteJid);
await this.updateChatUnreadMessages(remoteJid);
}

return result;
Expand All @@ -4758,15 +4756,17 @@ export class BaileysStartupService extends ChannelStartupService {

private async updateChatUnreadMessages(remoteJid: string): Promise<number> {
const [chat, unreadMessages] = await Promise.all([
this.prismaRepository.chat.findFirst({ where: { remoteJid } }),
// Use raw SQL to avoid JSON path issues
this.prismaRepository.$queryRaw`
SELECT COUNT(*)::int as count FROM "Message"
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'remoteJid' = ${remoteJid}
AND ("key"->>'fromMe')::boolean = false
AND "status" = ${status[3]}
`.then((result: any[]) => result[0]?.count || 0),
this.prismaRepository.chat.findFirst({ where: { remoteJid, instanceId: this.instanceId } }),
this.prismaRepository.message.count({
where: {
instanceId: this.instanceId,
AND: [
{ key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: remoteJid } },
{ key: { path: this.prismaRepository.jsonPath('fromMe'), equals: false } },
],
status: status[3],
},
}),
]);

if (chat && chat.unreadMessages !== unreadMessages) {
Expand All @@ -4779,6 +4779,18 @@ export class BaileysStartupService extends ChannelStartupService {
private async addLabel(labelId: string, instanceId: string, chatId: string) {
const id = cuid();

if (this.prismaRepository.isMysql) {
await this.prismaRepository.$executeRaw`
INSERT INTO \`Chat\` (\`id\`, \`instanceId\`, \`remoteJid\`, \`labels\`, \`createdAt\`, \`updatedAt\`)
VALUES (${id}, ${instanceId}, ${chatId}, JSON_ARRAY(${labelId}), NOW(), NOW())
ON DUPLICATE KEY UPDATE
\`labels\` = IF(JSON_CONTAINS(COALESCE(\`labels\`, JSON_ARRAY()), JSON_QUOTE(${labelId})),
\`labels\`, JSON_ARRAY_APPEND(COALESCE(\`labels\`, JSON_ARRAY()), '$', ${labelId})),
\`updatedAt\` = NOW()
`;
return;
}

await this.prismaRepository.$executeRawUnsafe(
`INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt")
VALUES ($4, $2, $3, to_jsonb(ARRAY[$1]::text[]), NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid")
Expand All @@ -4803,6 +4815,21 @@ export class BaileysStartupService extends ChannelStartupService {
private async removeLabel(labelId: string, instanceId: string, chatId: string) {
const id = cuid();

if (this.prismaRepository.isMysql) {
// Escape JSON_SEARCH wildcards so labels are matched literally.
const searchLabel = labelId.replace(/[!%_]/g, '!$&');
await this.prismaRepository.$executeRaw`
INSERT INTO \`Chat\` (\`id\`, \`instanceId\`, \`remoteJid\`, \`labels\`, \`createdAt\`, \`updatedAt\`)
VALUES (${id}, ${instanceId}, ${chatId}, JSON_ARRAY(), NOW(), NOW())
ON DUPLICATE KEY UPDATE
\`labels\` = IF(JSON_SEARCH(COALESCE(\`labels\`, JSON_ARRAY()), 'one', ${searchLabel}, '!') IS NULL,
COALESCE(\`labels\`, JSON_ARRAY()),
JSON_REMOVE(\`labels\`, JSON_UNQUOTE(JSON_SEARCH(\`labels\`, 'one', ${searchLabel}, '!')))),
\`updatedAt\` = NOW()
`;
return;
}

await this.prismaRepository.$executeRawUnsafe(
`INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt")
VALUES ($4, $2, $3, '[]'::jsonb, NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid")
Expand Down Expand Up @@ -5034,13 +5061,21 @@ export class BaileysStartupService extends ChannelStartupService {
messageType: query?.where?.messageType,
...timestampFilter,
AND: [
keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {},
keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {},
keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {},
keyFilters?.id ? { key: { path: this.prismaRepository.jsonPath('id'), equals: keyFilters?.id } } : {},
keyFilters?.fromMe
? { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: keyFilters?.fromMe } }
: {},
keyFilters?.participant
? { key: { path: this.prismaRepository.jsonPath('participant'), equals: keyFilters?.participant } }
: {},
{
OR: [
keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {},
keyFilters?.remoteJidAlt ? { key: { path: ['remoteJidAlt'], equals: keyFilters?.remoteJidAlt } } : {},
keyFilters?.remoteJid
? { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: keyFilters?.remoteJid } }
: {},
keyFilters?.remoteJidAlt
? { key: { path: this.prismaRepository.jsonPath('remoteJidAlt'), equals: keyFilters?.remoteJidAlt } }
: {},
],
},
],
Expand All @@ -5063,13 +5098,21 @@ export class BaileysStartupService extends ChannelStartupService {
messageType: query?.where?.messageType,
...timestampFilter,
AND: [
keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {},
keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {},
keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {},
keyFilters?.id ? { key: { path: this.prismaRepository.jsonPath('id'), equals: keyFilters?.id } } : {},
keyFilters?.fromMe
? { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: keyFilters?.fromMe } }
: {},
keyFilters?.participant
? { key: { path: this.prismaRepository.jsonPath('participant'), equals: keyFilters?.participant } }
: {},
{
OR: [
keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {},
keyFilters?.remoteJidAlt ? { key: { path: ['remoteJidAlt'], equals: keyFilters?.remoteJidAlt } } : {},
keyFilters?.remoteJid
? { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: keyFilters?.remoteJid } }
: {},
keyFilters?.remoteJidAlt
? { key: { path: this.prismaRepository.jsonPath('remoteJidAlt'), equals: keyFilters?.remoteJidAlt } }
: {},
],
},
],
Expand Down
50 changes: 24 additions & 26 deletions src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1546,7 +1546,7 @@ export class ChatwootService {
const lastMessage = await this.prismaRepository.message.findFirst({
where: {
key: {
path: ['fromMe'],
path: this.prismaRepository.jsonPath('fromMe'),
equals: false,
},
instanceId: instance.instanceId,
Expand Down Expand Up @@ -1576,7 +1576,7 @@ export class ChatwootService {
where: {
instanceId: instance.instanceId,
key: {
path: ['id'],
path: this.prismaRepository.jsonPath('id'),
equals: key.id,
},
},
Expand Down Expand Up @@ -1617,18 +1617,19 @@ export class ChatwootService {
return;
}

// Use raw SQL to avoid JSON path issues
const result = await this.prismaRepository.$executeRaw`
UPDATE "Message"
SET
"chatwootMessageId" = ${chatwootMessageIds.messageId},
"chatwootConversationId" = ${chatwootMessageIds.conversationId},
"chatwootInboxId" = ${chatwootMessageIds.inboxId},
"chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId},
"chatwootIsRead" = ${chatwootMessageIds.isRead || false}
WHERE "instanceId" = ${instance.instanceId}
AND "key"->>'id' = ${key.id}
`;
const { count: result } = await this.prismaRepository.message.updateMany({
where: {
instanceId: instance.instanceId,
key: { path: this.prismaRepository.jsonPath('id'), equals: key.id },
},
data: {
chatwootMessageId: chatwootMessageIds.messageId,
chatwootConversationId: chatwootMessageIds.conversationId ?? null,
chatwootInboxId: chatwootMessageIds.inboxId ?? null,
chatwootContactInboxSourceId: chatwootMessageIds.contactInboxSourceId ?? null,
chatwootIsRead: chatwootMessageIds.isRead || false,
},
});

this.logger.verbose(`Update result: ${result} rows affected`);

Expand All @@ -1642,15 +1643,12 @@ export class ChatwootService {
}

private async getMessageByKeyId(instance: InstanceDto, keyId: string): Promise<MessageModel> {
// Use raw SQL query to avoid JSON path issues with Prisma
const messages = await this.prismaRepository.$queryRaw`
SELECT * FROM "Message"
WHERE "instanceId" = ${instance.instanceId}
AND "key"->>'id' = ${keyId}
LIMIT 1
`;

return (messages as MessageModel[])[0] || null;
return this.prismaRepository.message.findFirst({
where: {
instanceId: instance.instanceId,
key: { path: this.prismaRepository.jsonPath('id'), equals: keyId },
},
});
}

private async getReplyToIds(
Expand Down Expand Up @@ -2025,7 +2023,7 @@ export class ChatwootService {
quotedMsg = await this.prismaRepository.message.findFirst({
where: {
key: {
path: ['id'],
path: this.prismaRepository.jsonPath('id'),
equals: quotedId,
},
chatwootMessageId: {
Expand Down Expand Up @@ -2337,7 +2335,7 @@ export class ChatwootService {
await this.prismaRepository.message.deleteMany({
where: {
key: {
path: ['id'],
path: this.prismaRepository.jsonPath('id'),
equals: body.key.id,
},
instanceId: instance.instanceId,
Expand Down Expand Up @@ -2679,7 +2677,7 @@ export class ChatwootService {
where: {
Instance: { name: instance.instanceName },
messageTimestamp: { gte: Number(dayjs().subtract(6, 'hours').unix()) },
AND: ids.map((id) => ({ key: { path: ['id'], not: id } })),
AND: ids.map((id) => ({ key: { path: this.prismaRepository.jsonPath('id'), not: id } })),
},
});

Expand Down
14 changes: 12 additions & 2 deletions src/api/repository/repository.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ConfigService } from '@config/env.config';
import { ConfigService, Database } from '@config/env.config';
import { Logger } from '@config/logger.config';
import { PrismaClient } from '@prisma/client';
import { Prisma, PrismaClient } from '@prisma/client';

export class Query<T> {
where?: T;
Expand All @@ -16,6 +16,16 @@ export class PrismaRepository extends PrismaClient {

private readonly logger = new Logger('PrismaRepository');

public get isMysql(): boolean {
return this.configService.get<Database>('DATABASE').PROVIDER === 'mysql';
}

public jsonPath(...segments: string[]): Prisma.JsonFilterBase<'Message'>['path'] {
// Prisma generates a different path type for each provider; match that provider at runtime.
const path = this.isMysql ? '$' + segments.map((segment) => `.${JSON.stringify(segment)}`).join('') : segments;
return path as Prisma.JsonFilterBase<'Message'>['path'];
}

public async onModuleInit() {
await this.$connect();
this.logger.info('Repository:Prisma - ON');
Expand Down
Loading