From 4cdcce63245fe5e1f178d1a671c27bfc8969e833 Mon Sep 17 00:00:00 2001 From: isra el Date: Sat, 18 Jul 2026 21:31:32 +0300 Subject: [PATCH] feat(api): search message history by body, recipient or sender Message history had no way to find an old message. The endpoint only accepted page, limit and type, so a client-side search box could only ever filter the 20 rows already loaded, which is more misleading than having no search. getMessages takes an optional `search` and matches it against the message body, recipient and sender. It composes with the existing type filter. The term is escaped before it reaches RegExp. This is not theoretical: an unescaped "(" throws a SyntaxError and fails the request, "." would match any character instead of a dot, and a nested quantifier is a ReDoS vector. The escaping is a separate, directly tested unit. Note on performance: the existing { device, type, receivedAt } index does not serve a regex $or, so a search is a scan within one device's messages. That is fine at current per-device volumes; a text index is the follow-up if it stops being fine. Frontend: useDeviceMessages takes `search`, joins it to the query key so each term caches separately, and builds the query with URLSearchParams so terms containing & or = are encoded rather than corrupting the URL. Co-Authored-By: Claude Fable 5 --- api/src/gateway/escape-regexp.spec.ts | 35 +++++++++++++++ api/src/gateway/escape-regexp.ts | 11 +++++ api/src/gateway/gateway.controller.ts | 5 ++- api/src/gateway/gateway.service.spec.ts | 59 +++++++++++++++++++++++++ api/src/gateway/gateway.service.ts | 15 +++++++ web/lib/api/hooks.ts | 29 +++++++----- 6 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 api/src/gateway/escape-regexp.spec.ts create mode 100644 api/src/gateway/escape-regexp.ts diff --git a/api/src/gateway/escape-regexp.spec.ts b/api/src/gateway/escape-regexp.spec.ts new file mode 100644 index 0000000..56679bc --- /dev/null +++ b/api/src/gateway/escape-regexp.spec.ts @@ -0,0 +1,35 @@ +import { escapeRegExp } from './escape-regexp' + +describe('escapeRegExp', () => { + it('leaves plain text untouched', () => { + expect(escapeRegExp('hello world')).toBe('hello world') + expect(escapeRegExp('+14155550101')).toBe('\\+14155550101') + }) + + it('makes an unbalanced paren safe to compile', () => { + // Unescaped, `new RegExp('(')` throws and would fail the whole request. + expect(() => new RegExp(escapeRegExp('('))).not.toThrow() + expect(() => new RegExp(escapeRegExp('a)b['))).not.toThrow() + }) + + it('treats wildcards as literal characters', () => { + const pattern = new RegExp(escapeRegExp('.*'), 'i') + // A literal ".*" must not match arbitrary text. + expect(pattern.test('anything at all')).toBe(false) + expect(pattern.test('literally .* here')).toBe(true) + }) + + it('treats a dot as a dot, not "any character"', () => { + const pattern = new RegExp(escapeRegExp('a.c'), 'i') + expect(pattern.test('abc')).toBe(false) + expect(pattern.test('a.c')).toBe(true) + }) + + it('defuses a nested quantifier (ReDoS shape)', () => { + const pattern = new RegExp(escapeRegExp('(a+)+$'), 'i') + const start = Date.now() + pattern.test('a'.repeat(2000) + 'b') + // As a literal it is a plain substring scan, not catastrophic backtracking. + expect(Date.now() - start).toBeLessThan(100) + }) +}) diff --git a/api/src/gateway/escape-regexp.ts b/api/src/gateway/escape-regexp.ts new file mode 100644 index 0000000..a45d74c --- /dev/null +++ b/api/src/gateway/escape-regexp.ts @@ -0,0 +1,11 @@ +/** + * Escape every regular-expression metacharacter in a string so it can be + * embedded in a RegExp as a literal. + * + * Search terms come straight from user input. Without this, "(" alone throws + * a SyntaxError and fails the request, "." matches any character instead of a + * dot, and a nested quantifier is a ReDoS vector. + */ +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 0eab5a2..450c972 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/api/src/gateway/gateway.controller.ts @@ -160,8 +160,9 @@ export class GatewayController { const page = req.query.page ? parseInt(req.query.page, 10) : 1; const limit = req.query.limit ? Math.min(parseInt(req.query.limit, 10), 100) : 50; const type = req.query.type || ''; - - const result = await this.gatewayService.getMessages(deviceId, type, page, limit); + const search = req.query.search || ''; + + const result = await this.gatewayService.getMessages(deviceId, type, page, limit, search); return result; } diff --git a/api/src/gateway/gateway.service.spec.ts b/api/src/gateway/gateway.service.spec.ts index 7f43cd8..45eeb8e 100644 --- a/api/src/gateway/gateway.service.spec.ts +++ b/api/src/gateway/gateway.service.spec.ts @@ -802,6 +802,65 @@ describe('GatewayService', () => { HttpException, ) }) + + it('should search across message body, recipient and sender', async () => { + await service.getMessages(mockDeviceId, '', 1, 10, 'alice') + + const expectedQuery = { + device: mockDevice._id, + $or: [ + { message: /alice/i }, + { recipient: /alice/i }, + { sender: /alice/i }, + ], + } + + expect(mockSmsModel.countDocuments).toHaveBeenCalledWith(expectedQuery) + expect(mockSmsModel.find).toHaveBeenCalledWith( + expectedQuery, + null, + expect.any(Object), + ) + }) + + it('should combine search with the type filter', async () => { + await service.getMessages(mockDeviceId, 'sent', 1, 10, 'alice') + + expect(mockSmsModel.find).toHaveBeenCalledWith( + expect.objectContaining({ + device: mockDevice._id, + type: SMSType.SENT, + $or: expect.any(Array), + }), + null, + expect.any(Object), + ) + }) + + it('should ignore an empty or whitespace-only search', async () => { + await service.getMessages(mockDeviceId, '', 1, 10, ' ') + + expect(mockSmsModel.find).toHaveBeenCalledWith( + { device: mockDevice._id }, + null, + expect.any(Object), + ) + }) + + it('should escape regex metacharacters in the search term', async () => { + // Unescaped, this throws a SyntaxError and fails the request. + await expect( + service.getMessages(mockDeviceId, '', 1, 10, '('), + ).resolves.toBeDefined() + + // A wildcard must be matched literally, not treated as "any character". + await service.getMessages(mockDeviceId, '', 1, 10, '.*') + + const call = mockSmsModel.find.mock.calls.at(-1) + const messagePattern = call[0].$or[0].message as RegExp + expect(messagePattern.test('anything at all')).toBe(false) + expect(messagePattern.test('contains .* literally')).toBe(true) + }) }) describe('getStatsForUser', () => { diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index fe396c9..440ea60 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -24,6 +24,7 @@ import { WebhookEvent } from '../webhook/webhook-event.enum' import { WebhookService } from '../webhook/webhook.service' import { BillingService } from '../billing/billing.service' import { SmsQueueService } from './queue/sms-queue.service' +import { escapeRegExp } from './escape-regexp' @Injectable() export class GatewayService { @@ -947,6 +948,7 @@ export class GatewayService { type = '', page = 1, limit = 50, + search = '', ): Promise<{ data: any[]; meta: any }> { const device = await this.deviceModel.findById(deviceId) @@ -972,6 +974,19 @@ export class GatewayService { query.type = SMSType.RECEIVED } + // Free-text search across the message body and either party's number. + // The input is escaped before it reaches RegExp: an unescaped "(" throws + // and fails the request, and a crafted pattern is a ReDoS vector. + const trimmedSearch = search?.trim() + if (trimmedSearch) { + const pattern = new RegExp(escapeRegExp(trimmedSearch), 'i') + query.$or = [ + { message: pattern }, + { recipient: pattern }, + { sender: pattern }, + ] + } + // Get total count for pagination metadata const total = await this.smsModel.countDocuments(query) diff --git a/web/lib/api/hooks.ts b/web/lib/api/hooks.ts index a6ec32f..4c7444a 100644 --- a/web/lib/api/hooks.ts +++ b/web/lib/api/hooks.ts @@ -244,6 +244,7 @@ export type DeviceMessagesParams = { type?: string page?: number limit?: number + search?: string } export type DeviceMessagesEnvelope = { @@ -252,23 +253,31 @@ export type DeviceMessagesEnvelope = { } // Returns the raw { data, meta } message-history envelope for a device. +// `search` is handled server-side (gateway getMessages matches it against the +// message body, recipient and sender), so it searches all of a device's +// history rather than only the page already loaded. export function useDeviceMessages( deviceId: string, params: DeviceMessagesParams = {}, options?: QueryOpts ) { - const { type = 'all', page = 1, limit = 20 } = params + const { type = 'all', page = 1, limit = 20, search = '' } = params return useQuery({ - queryKey: queryKeys.deviceMessages(deviceId, { type, page, limit }), + // search joins the key so each term caches separately. + queryKey: queryKeys.deviceMessages(deviceId, { type, page, limit, search }), enabled: !!deviceId, - queryFn: () => - httpBrowserClient - .get( - `${ApiEndpoints.gateway.getMessages( - deviceId - )}?type=${type}&page=${page}&limit=${limit}` - ) - .then(unwrapBody), + queryFn: () => { + const query = new URLSearchParams({ + type, + page: String(page), + limit: String(limit), + }) + if (search) query.set('search', search) + + return httpBrowserClient + .get(`${ApiEndpoints.gateway.getMessages(deviceId)}?${query}`) + .then(unwrapBody) + }, ...options, }) }