Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ WEBHOOK_SSRF_PROTECT=true
# Per-file cap on status (Story) media persisted to disk/S3. A status whose media exceeds this is
# stored omitted (mediaOmitted=true) rather than rejected. A non-positive/garbage value falls back.
# STATUS_MEDIA_MAX_BYTES=10485760 # default 10 MiB
# Orphaned status media reconciliation: how often the sweep re-lists status media files to find
# ones no row references (crash leftovers), and how long a file must be seen unreferenced before
# it is deleted. Non-positive/garbage values fall back to the defaults.
# STATUS_ORPHAN_SWEEP_INTERVAL_MS=3600000 # default 1h
# STATUS_ORPHAN_GRACE_MS=3600000 # default 1h
# Cap on the final rendered text of a send-template request (after variable substitution). Over-cap
# renders are rejected with 400, never silently truncated. A non-positive/garbage value falls back.
# TEMPLATE_RENDER_MAX_CHARS=65536 # default 64 KiB
# Cap on concurrently-running bulk batches (POST .../messages/send-bulk). 0 = unlimited. A non-finite
# or negative value falls back to the default.
# BULK_MAX_CONCURRENT_BATCHES=50 # default 50
Expand Down
2 changes: 2 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7489,6 +7489,7 @@
},
"participants": {
"description": "Participant WhatsApp IDs (e.g. 628123456789@c.us)",
"maxItems": 256,
"type": "array",
"items": {
"type": "string"
Expand All @@ -7505,6 +7506,7 @@
"properties": {
"participants": {
"description": "Participant WhatsApp IDs (e.g. 628123456789@c.us)",
"maxItems": 256,
"type": "array",
"items": {
"type": "string"
Expand Down
24 changes: 24 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,30 @@ export default () => ({
const n = parseInt(process.env.STATUS_MEDIA_MAX_BYTES ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 10 * 1024 * 1024;
})(),
// How often the reconciliation sweep re-lists status media files to find ones no row references
// (default 1h). Orphans only arise from a crash between the media write and its row update.
orphanSweepIntervalMs: (() => {
const n = parseInt(process.env.STATUS_ORPHAN_SWEEP_INTERVAL_MS ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 60 * 60 * 1000;
})(),
// How long an unreferenced status media file must be observed by the sweep before it is deleted
// (default 1h), so a file mid-ingest is never reaped. A non-positive/garbage value falls back.
orphanGraceMs: (() => {
const n = parseInt(process.env.STATUS_ORPHAN_GRACE_MS ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 60 * 60 * 1000;
})(),
},

// Message-template rendering
template: {
// Cap on the FINAL rendered text of a send-template request (header+body+footer joined, after
// caller-supplied variable substitution; default 64 KiB — also WhatsApp's own text-message
// ceiling). Without it a small template plus a huge variable inflates the payload to the
// engine/DB unboundedly. Over-cap renders are rejected (400), never silently truncated.
renderMaxChars: (() => {
const n = parseInt(process.env.TEMPLATE_RENDER_MAX_CHARS ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 64 * 1024;
})(),
},

// Storage configuration
Expand Down
14 changes: 14 additions & 0 deletions src/modules/group/dto/group.dto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ describe('group DTO validation', () => {
expect(errors.length).toBeGreaterThan(0);
});

it('accepts a participants array at the batch cap (256)', async () => {
const participants = Array.from({ length: 256 }, (_, i) => `628100000${String(i).padStart(3, '0')}@c.us`);
expect(await errorsFor(ParticipantsDto, { participants })).toHaveLength(0);
expect(await errorsFor(CreateGroupDto, { name: 'G', participants })).toHaveLength(0);
});

it('rejects a participants array beyond the batch cap (the engine works the list sequentially)', async () => {
const participants = Array.from({ length: 257 }, (_, i) => `628100000${String(i).padStart(3, '0')}@c.us`);
const batchErrors = await errorsFor(ParticipantsDto, { participants });
expect(batchErrors.some(e => e.property === 'participants')).toBe(true);
const createErrors = await errorsFor(CreateGroupDto, { name: 'G', participants });
expect(createErrors.some(e => e.property === 'participants')).toBe(true);
});

it('still rejects unknown properties (forbidNonWhitelisted intact)', async () => {
const errors = await errorsFor(ParticipantsDto, { participants: ['x@c.us'], hacker: true });
expect(errors.some(e => e.property === 'hacker')).toBe(true);
Expand Down
24 changes: 22 additions & 2 deletions src/modules/group/dto/group.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
ArrayNotEmpty,
ArrayMaxSize,
IsString,
IsNotEmpty,
MaxLength,
Expand All @@ -17,24 +18,43 @@ import { ToStrictBoolean, ToStrictNumber } from '../../../common/utils/strict-bo
export const GROUP_NAME_MAX_LENGTH = 100;
export const GROUP_DESCRIPTION_MAX_LENGTH = 1024;

/**
* Max participants accepted in one group-create or participant-batch request. The engine works the
* list sequentially (one WhatsApp round-trip per participant, with an honest per-participant
* result), so an unbounded array turns a single HTTP call into minutes of serial engine work.
* 256 stays well inside WhatsApp's own group-size limit while keeping one request's work bounded;
* larger imports batch across requests.
*/
export const GROUP_PARTICIPANTS_MAX = 256;

export class CreateGroupDto {
@ApiProperty({ description: 'Group subject/name', maxLength: GROUP_NAME_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(GROUP_NAME_MAX_LENGTH)
name: string;

@ApiProperty({ description: 'Participant WhatsApp IDs (e.g. 628123456789@c.us)', type: [String] })
@ApiProperty({
description: 'Participant WhatsApp IDs (e.g. 628123456789@c.us)',
type: [String],
maxItems: GROUP_PARTICIPANTS_MAX,
})
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(GROUP_PARTICIPANTS_MAX)
@IsString({ each: true })
participants: string[];
}

export class ParticipantsDto {
@ApiProperty({ description: 'Participant WhatsApp IDs (e.g. 628123456789@c.us)', type: [String] })
@ApiProperty({
description: 'Participant WhatsApp IDs (e.g. 628123456789@c.us)',
type: [String],
maxItems: GROUP_PARTICIPANTS_MAX,
})
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(GROUP_PARTICIPANTS_MAX)
@IsString({ each: true })
participants: string[];
}
Expand Down
35 changes: 34 additions & 1 deletion src/modules/group/group.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
import { GroupService } from './group.service';
import { SessionService } from '../session/session.service';
import { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface';
import { EngineNotSupportedError } from '../../common/errors/engine-not-supported.error';
import { EngineRefusedError } from '../../common/errors/engine-refused.error';

describe('GroupService', () => {
const makeService = (engine: Partial<IWhatsAppEngine> | undefined) => {
Expand Down Expand Up @@ -149,5 +150,37 @@ describe('GroupService', () => {
expect(engine.setGroupMessagesAdminsOnly).not.toHaveBeenCalled();
expect(engine.setGroupInfoAdminsOnly).not.toHaveBeenCalled();
});

it('names the failed field AND the applied ones when a patch partially applies', async () => {
// ephemeralSeconds applied, then announce failed: the client must learn the group is now in a
// mixed state (and which subset took effect), not receive a bare engine error.
const engine = {
setGroupEphemeral: jest.fn().mockResolvedValue(undefined),
setGroupMessagesAdminsOnly: jest.fn().mockRejectedValue(new EngineRefusedError('not a group admin')),
setGroupInfoAdminsOnly: jest.fn().mockResolvedValue(undefined),
};
const svc = makeService(engine);
const error = await svc
.updateGroupSettings('s1', 'g1', { announce: true, locked: true, ephemeralSeconds: 86400 })
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(403); // the underlying refusal's status survives
expect((error as HttpException).message).toContain("'announce' failed");
expect((error as HttpException).message).toContain('ephemeralSeconds');
// The patch stops at the failure: locked is never attempted.
expect(engine.setGroupInfoAdminsOnly).not.toHaveBeenCalled();
});

it('propagates a first-field failure unchanged (nothing applied → no partial state to report)', async () => {
const engine = {
setGroupEphemeral: jest.fn().mockRejectedValue(new EngineRefusedError('not a group admin')),
setGroupMessagesAdminsOnly: jest.fn().mockResolvedValue(undefined),
};
const svc = makeService(engine);
await expect(
svc.updateGroupSettings('s1', 'g1', { announce: true, ephemeralSeconds: 86400 }),
).rejects.toBeInstanceOf(EngineRefusedError);
expect(engine.setGroupMessagesAdminsOnly).not.toHaveBeenCalled();
});
});
});
32 changes: 28 additions & 4 deletions src/modules/group/group.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { Injectable, BadRequestException, HttpException, HttpStatus, NotFoundException } from '@nestjs/common';
import { SessionService } from '../session/session.service';
import { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface';
import { paginate, ListOptions } from '../../common/utils/paginate';
Expand Down Expand Up @@ -98,6 +98,11 @@ export class GroupService {
* Ordering matters: ephemeralSeconds is applied FIRST because it is the only field with a
* deterministic per-engine refusal (wwjs always 501s it). Applying announce/locked first would
* leave a silently half-applied patch behind when the ephemeral call then throws.
*
* A failure on the FIRST applied field propagates unchanged (nothing was applied, so the patch
* simply failed). A failure on a LATER field means the group is now in a mixed state, so the
* error names the failed field and the ones already applied — the caller can reconcile instead
* of guessing which subset took effect. The wrapped error keeps the underlying HTTP status.
*/
async updateGroupSettings(
sessionId: string,
Expand All @@ -109,14 +114,33 @@ export class GroupService {
throw new BadRequestException('At least one of announce, locked, ephemeralSeconds must be provided');
}
const engine = this.getEngine(sessionId);
const steps: Array<[field: string, apply: () => Promise<unknown>]> = [];
if (ephemeralSeconds !== undefined) {
await engine.setGroupEphemeral(groupId, ephemeralSeconds);
steps.push(['ephemeralSeconds', () => engine.setGroupEphemeral(groupId, ephemeralSeconds)]);
}
if (announce !== undefined) {
await engine.setGroupMessagesAdminsOnly(groupId, announce);
steps.push(['announce', () => engine.setGroupMessagesAdminsOnly(groupId, announce)]);
}
if (locked !== undefined) {
await engine.setGroupInfoAdminsOnly(groupId, locked);
steps.push(['locked', () => engine.setGroupInfoAdminsOnly(groupId, locked)]);
}

const applied: string[] = [];
for (const [field, apply] of steps) {
try {
await apply();
applied.push(field);
} catch (error) {
if (applied.length === 0) throw error;
const status = error instanceof HttpException ? error.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const detail = error instanceof Error ? error.message : String(error);
throw new HttpException(
`Group settings only partially applied: '${field}' failed (${detail}); already applied: ${applied.join(
', ',
)}`,
status,
);
}
}
}
}
46 changes: 46 additions & 0 deletions src/modules/message/message.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,52 @@ describe('MessageService', () => {
);
expect(mockEngine.sendTextMessage).not.toHaveBeenCalled();
});

it('rejects an over-cap render with a 400 naming the limit (never truncated silently)', async () => {
(templateService.resolve as jest.Mock).mockResolvedValue(mockTemplate({ body: 'Hi {{customer}}' }));

const error = await service
.sendTemplate('sess-1', {
chatId: 'test@c.us',
templateId: 'tpl-1',
vars: { customer: 'x'.repeat(70_000) },
})
.catch((e: unknown) => e);

expect(error).toBeInstanceOf(BadRequestException);
expect((error as Error).message).toContain('over the 65536-character limit');
expect(mockEngine.sendTextMessage).not.toHaveBeenCalled();
});

it('renders at-or-under the cap unchanged', async () => {
(templateService.resolve as jest.Mock).mockResolvedValue(mockTemplate({ body: 'Hi {{customer}}' }));
// 'Hi ' + name lands exactly on the 64 KiB default cap — at the cap is NOT over it.
const name = 'y'.repeat(64 * 1024 - 3);

await service.sendTemplate('sess-1', { chatId: 'test@c.us', templateId: 'tpl-1', vars: { customer: name } });

expect(mockEngine.sendTextMessage).toHaveBeenCalledWith('test@c.us', `Hi ${name}`);
});

it('honors a configured template.renderMaxChars override', async () => {
const configService = {
get: (key: string, fallback: unknown) => (key === 'template.renderMaxChars' ? 10 : fallback),
} as unknown as ConstructorParameters<typeof MessageService>[5];
const capped = new MessageService(
repository as Repository<Message>,
sessionService as unknown as SessionService,
hookManager as HookManager,
templateService as unknown as TemplateService,
lidMappingStore as unknown as LidMappingStoreService,
configService,
);
(templateService.resolve as jest.Mock).mockResolvedValue(mockTemplate({ body: 'Hello {{customer}}' }));

await expect(
capped.sendTemplate('sess-1', { chatId: 'test@c.us', templateId: 'tpl-1', vars: { customer: 'Alice' } }),
).rejects.toThrow(/over the 10-character limit/);
expect(mockEngine.sendTextMessage).not.toHaveBeenCalled();
});
});

// ── send-hook chokepoint ──────────────────────────────────────────
Expand Down
16 changes: 16 additions & 0 deletions src/modules/message/message.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface GetMessagesOptions {
offset?: number;
}

/** Default cap on a rendered template's final text; overridable via TEMPLATE_RENDER_MAX_CHARS. */
export const DEFAULT_TEMPLATE_RENDER_MAX_CHARS = 64 * 1024;

/**
* Outbound sends are executed directly against the WhatsApp engine, not via a BullMQ queue.
*
Expand Down Expand Up @@ -141,6 +144,10 @@ export class MessageService {
* existing {@link sendText} path so plugin hooks, persistence, and status
* tracking are reused. Throws NotFoundException when the template cannot be
* resolved by id or name.
*
* The FINAL rendered text is capped at template.renderMaxChars (default 64 KiB): caller-supplied
* variables can inflate a small template unboundedly, so an over-cap render is rejected with a
* 400 naming the limit rather than truncated silently or pushed to the engine/DB as-is.
*/
async sendTemplate(sessionId: string, dto: SendTemplateMessageDto): Promise<MessageResponseDto> {
const template = await this.templateService.resolve(sessionId, {
Expand All @@ -154,6 +161,15 @@ export class MessageService {
.map(segment => renderTemplate(segment, vars));
const text = segments.join('\n\n');

const maxChars =
this.configService?.get<number>('template.renderMaxChars', DEFAULT_TEMPLATE_RENDER_MAX_CHARS) ??
DEFAULT_TEMPLATE_RENDER_MAX_CHARS;
if (text.length > maxChars) {
throw new BadRequestException(
`Rendered template is ${text.length} characters, over the ${maxChars}-character limit`,
);
}

return this.sendText(sessionId, { chatId: dto.chatId, text });
}

Expand Down
Loading
Loading