Add partner SMS gateway via Gama SendQuick and document it.
Expose POST /public/sms/send with API key + domain allowlist for external backends like Balout, wire Meshkee OTP/message sends to Gama, and publish Partner SMS docs on /docs/website. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
267a218c26
commit
f4295e780c
@@ -0,0 +1,19 @@
|
||||
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SendPublicSmsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(253)
|
||||
domain!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
to!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(1)
|
||||
@MaxLength(700)
|
||||
message!: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
|
||||
import { PublicSmsService } from './public-sms.service';
|
||||
import { SMS_PARTNER_DOMAIN_KEY, SmsPartnerGuard } from './sms-partner.guard';
|
||||
|
||||
@Controller('public/sms')
|
||||
export class PublicSmsController {
|
||||
constructor(private readonly service: PublicSmsService) {}
|
||||
|
||||
@Post('send')
|
||||
@UseGuards(SmsPartnerGuard)
|
||||
send(
|
||||
@Body() dto: SendPublicSmsDto,
|
||||
@Req() req: Request & { [SMS_PARTNER_DOMAIN_KEY]?: string },
|
||||
) {
|
||||
const partnerDomain = req[SMS_PARTNER_DOMAIN_KEY] ?? dto.domain;
|
||||
return this.service.send(partnerDomain, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PublicSmsController } from './public-sms.controller';
|
||||
import { PublicSmsService } from './public-sms.service';
|
||||
import { SmsPartnerGuard } from './sms-partner.guard';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [PublicSmsController],
|
||||
providers: [PublicSmsService, SmsPartnerGuard],
|
||||
})
|
||||
export class PublicSmsModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
|
||||
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
|
||||
|
||||
const PARTNER_LIMIT_PER_MINUTE = 30;
|
||||
const DESTINATION_LIMIT_PER_MINUTE = 5;
|
||||
const WINDOW_SECONDS = 60;
|
||||
|
||||
@Injectable()
|
||||
export class PublicSmsService {
|
||||
private readonly logger = new Logger(PublicSmsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly sms: SmsService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
async send(partnerDomain: string, dto: SendPublicSmsDto) {
|
||||
if (!this.sms.isEnabled()) {
|
||||
throw new ServiceUnavailableException('SMS is currently disabled');
|
||||
}
|
||||
|
||||
const msisdn = toGamaMsisdn(dto.to);
|
||||
if (!msisdn) {
|
||||
throw new HttpException('Invalid cellphone number', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.assertRateLimits(partnerDomain, msisdn);
|
||||
|
||||
const result = await this.sms.sendMessage(msisdn, dto.message);
|
||||
this.logger.log(
|
||||
`Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
serverId: result.serverId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRateLimits(domain: string, msisdn: string): Promise<void> {
|
||||
const partnerOk = await this.redis.incrementWithLimit(
|
||||
`sms:rl:partner:${domain}`,
|
||||
PARTNER_LIMIT_PER_MINUTE,
|
||||
WINDOW_SECONDS,
|
||||
);
|
||||
if (!partnerOk) {
|
||||
throw new HttpException(
|
||||
'Too many SMS requests for this partner. Try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
const destOk = await this.redis.incrementWithLimit(
|
||||
`sms:rl:dest:${msisdn}`,
|
||||
DESTINATION_LIMIT_PER_MINUTE,
|
||||
WINDOW_SECONDS,
|
||||
);
|
||||
if (!destOk) {
|
||||
throw new HttpException(
|
||||
'Too many SMS requests for this destination. Try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
normalizePartnerDomain,
|
||||
parseSmsPartners,
|
||||
timingSafeEqualString,
|
||||
} from './sms-partners.util';
|
||||
|
||||
export const SMS_PARTNER_DOMAIN_KEY = 'smsPartnerDomain';
|
||||
|
||||
@Injectable()
|
||||
export class SmsPartnerGuard implements CanActivate {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const partners = parseSmsPartners(this.config.get<string>('SMS_PARTNERS'));
|
||||
if (partners.size === 0) {
|
||||
throw new UnauthorizedException('SMS partner gateway is not configured');
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const providedKey = String(req.headers['x-api-key'] ?? '').trim();
|
||||
if (!providedKey) {
|
||||
throw new UnauthorizedException('Missing X-Api-Key');
|
||||
}
|
||||
|
||||
const body = (req.body ?? {}) as { domain?: unknown };
|
||||
const domain = normalizePartnerDomain(String(body.domain ?? ''));
|
||||
if (!domain) {
|
||||
throw new UnauthorizedException('Missing or invalid domain');
|
||||
}
|
||||
|
||||
const expectedKey = partners.get(domain);
|
||||
if (!expectedKey || !timingSafeEqualString(providedKey, expectedKey)) {
|
||||
throw new UnauthorizedException('Invalid API key or domain');
|
||||
}
|
||||
|
||||
(req as Request & { [SMS_PARTNER_DOMAIN_KEY]?: string })[SMS_PARTNER_DOMAIN_KEY] =
|
||||
domain;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
/** Lowercase host, strip protocol/path/port, strip leading www. */
|
||||
export function normalizePartnerDomain(raw: string): string {
|
||||
let value = raw.trim().toLowerCase();
|
||||
if (!value) return '';
|
||||
|
||||
value = value.replace(/^https?:\/\//, '');
|
||||
value = value.split('/')[0] ?? '';
|
||||
value = value.split(':')[0] ?? '';
|
||||
if (value.startsWith('www.')) {
|
||||
value = value.slice(4);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Parse `domain:apiKey,domain2:apiKey2` into a map keyed by normalized domain. */
|
||||
export function parseSmsPartners(raw: string | undefined | null): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!raw?.trim()) return map;
|
||||
|
||||
for (const part of raw.split(',')) {
|
||||
const entry = part.trim();
|
||||
if (!entry) continue;
|
||||
const idx = entry.indexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
const domain = normalizePartnerDomain(entry.slice(0, idx));
|
||||
const apiKey = entry.slice(idx + 1).trim();
|
||||
if (domain && apiKey) {
|
||||
map.set(domain, apiKey);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function timingSafeEqualString(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a);
|
||||
const right = Buffer.from(b);
|
||||
if (left.length !== right.length) return false;
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
Reference in New Issue
Block a user