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 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-18 21:31:32 +03:00
parent ffbbabdbd3
commit 4cdcce6324
6 changed files with 142 additions and 12 deletions

View File

@@ -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)
})
})

View File

@@ -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, '\\$&')
}

View File

@@ -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;
}

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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<DeviceMessagesEnvelope>
) {
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<DeviceMessagesEnvelope>),
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<DeviceMessagesEnvelope>)
},
...options,
})
}