mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
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:
35
api/src/gateway/escape-regexp.spec.ts
Normal file
35
api/src/gateway/escape-regexp.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
11
api/src/gateway/escape-regexp.ts
Normal file
11
api/src/gateway/escape-regexp.ts
Normal 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, '\\$&')
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user