mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Brand OTP SMS with business name and support local SMS proxy.
Append tenant Farsi name to verification SMS, allow optional domain on send-otp, and add SMS_PROXY_* for local delivery via production. Also include websites SSL sync agent/API wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
77f3cb2a67
commit
8f05ee2b58
@@ -31,6 +31,11 @@ SMS_GAMA_SOURCE_SERVICE=5000110005
|
|||||||
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
|
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
|
||||||
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
||||||
SMS_PARTNERS=
|
SMS_PARTNERS=
|
||||||
|
# Local-dev only: proxy SMS delivery through production partner gateway
|
||||||
|
# (OTP still stored in local Redis). Leave unset on production.
|
||||||
|
# SMS_PROXY_URL=https://api.meshkee.com/api/v1/public/sms/send
|
||||||
|
# SMS_PROXY_DOMAIN=meshkee.local
|
||||||
|
# SMS_PROXY_API_KEY=
|
||||||
|
|
||||||
# Object storage (Parspack / S3-compatible)
|
# Object storage (Parspack / S3-compatible)
|
||||||
# Bucket id is the Parspack account id. Object keys live under meshkee/...
|
# Bucket id is the Parspack account id. Object keys live under meshkee/...
|
||||||
|
|||||||
@@ -641,7 +641,13 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["cellNumber"],
|
"required": ["cellNumber"],
|
||||||
"properties": { "cellNumber": { "type": "string" } }
|
"properties": {
|
||||||
|
"cellNumber": { "type": "string" },
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Tenant host/apex used to brand the OTP SMS with the business Farsi name"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# Endpoints (X-Deploy-Token):
|
# Endpoints (X-Deploy-Token):
|
||||||
# POST /deploy { slug } — git pull + build + pm2 restart
|
# POST /deploy { slug } — git pull + build + pm2 restart
|
||||||
# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist
|
# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist
|
||||||
|
# POST /ssl { host, slug? } — certbot for apex + www (nginx must exist)
|
||||||
# GET /health
|
# GET /health
|
||||||
#
|
#
|
||||||
# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS
|
# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS
|
||||||
|
|||||||
@@ -176,6 +176,42 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/ssl') {
|
||||||
|
if (!assertAuth(req, res)) return;
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readJson(req);
|
||||||
|
} catch {
|
||||||
|
return send(res, 400, { error: 'invalid json' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = String(body.host || '').trim().toLowerCase();
|
||||||
|
const slug = String(body.slug || '').trim();
|
||||||
|
|
||||||
|
if (!host || !/^[a-z0-9.-]+$/.test(host)) {
|
||||||
|
return send(res, 400, { error: 'valid host is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug && !allowedSlugs().has(slug)) {
|
||||||
|
return send(res, 400, { error: 'unknown or disallowed slug' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runScript('/opt/websites-agent/ssl.sh', [host]);
|
||||||
|
return send(res, 200, {
|
||||||
|
status: 'issued',
|
||||||
|
host,
|
||||||
|
log: (result.stdout || '').slice(-2000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return send(res, 500, {
|
||||||
|
error: 'ssl issue failed',
|
||||||
|
detail: err instanceof Error ? err.message.slice(0, 2000) : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return send(res, 404, { error: 'not found' });
|
return send(res, 404, { error: 'not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Issue / renew Let's Encrypt cert for a storefront apex (+ www) via nginx plugin.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${1:-}"
|
||||||
|
|
||||||
|
if [[ -z "$HOST" ]]; then
|
||||||
|
echo "usage: ssl.sh <host>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ ]]; then
|
||||||
|
echo "invalid host: $HOST" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
|
||||||
|
NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST"
|
||||||
|
LOG_DIR="/var/log/websites"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG="$LOG_DIR/ssl-$HOST.log"
|
||||||
|
exec >>"$LOG" 2>&1
|
||||||
|
|
||||||
|
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) ssl start: $HOST ===="
|
||||||
|
|
||||||
|
if [[ ! -f "$NGINX_AVAILABLE" && ! -f "$NGINX_ENABLED" ]]; then
|
||||||
|
echo "missing nginx site for $HOST (run provision first)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v certbot >/dev/null 2>&1; then
|
||||||
|
echo "certbot not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
certbot --nginx -d "$HOST" -d "www.$HOST" \
|
||||||
|
--non-interactive --agree-tos --register-unsafely-without-email --redirect
|
||||||
|
|
||||||
|
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) ssl ok: $HOST ===="
|
||||||
@@ -80,7 +80,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('send-otp')
|
@Post('send-otp')
|
||||||
sendOtp(@Body() dto: SendOtpDto) {
|
sendOtp(@Body() dto: SendOtpDto) {
|
||||||
return this.authService.sendOtp(dto.cellNumber);
|
return this.authService.sendOtp(dto.cellNumber, dto.domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('verify-otp')
|
@Post('verify-otp')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
HttpException,
|
||||||
Injectable,
|
Injectable,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
@@ -271,7 +272,7 @@ export class AuthService {
|
|||||||
return { message: 'Password changed successfully' };
|
return { message: 'Password changed successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOtp(cellNumber: string) {
|
async sendOtp(cellNumber: string, domain?: string) {
|
||||||
if (!this.sms.isEnabled()) {
|
if (!this.sms.isEnabled()) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -288,15 +289,28 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Cell number is not registered');
|
throw new UnauthorizedException('Cell number is not registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let businessNameFa: string | undefined;
|
||||||
|
const host = domain?.trim();
|
||||||
|
if (host) {
|
||||||
|
try {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
businessNameFa =
|
||||||
|
business.nameFa?.trim() || business.name?.trim() || undefined;
|
||||||
|
} catch {
|
||||||
|
// Domain may be unknown — still send OTP without branding suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const code = this.generateOtpCode();
|
const code = this.generateOtpCode();
|
||||||
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sms.sendVerificationCode(cellNumber, code);
|
await this.sms.sendVerificationCode(cellNumber, code, businessNameFa);
|
||||||
} catch {
|
} catch (err) {
|
||||||
throw new ServiceUnavailableException(
|
if (err instanceof HttpException) {
|
||||||
'SMS provider is not configured yet',
|
throw err;
|
||||||
);
|
}
|
||||||
|
throw new ServiceUnavailableException('Unable to send SMS');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, Matches } from 'class-validator';
|
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class SendOtpDto {
|
export class SendOtpDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -6,4 +6,10 @@ export class SendOtpDto {
|
|||||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||||
})
|
})
|
||||||
cellNumber!: string;
|
cellNumber!: string;
|
||||||
|
|
||||||
|
/** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(253)
|
||||||
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-2
@@ -27,7 +27,11 @@ export class SmsService {
|
|||||||
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendVerificationCode(cellNumber: string, code: string): Promise<SmsSendResult> {
|
async sendVerificationCode(
|
||||||
|
cellNumber: string,
|
||||||
|
code: string,
|
||||||
|
businessNameFa?: string,
|
||||||
|
): Promise<SmsSendResult> {
|
||||||
if (!this.isEnabled()) {
|
if (!this.isEnabled()) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
|
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
|
||||||
@@ -35,7 +39,10 @@ export class SmsService {
|
|||||||
return { serverId: 'disabled' };
|
return { serverId: 'disabled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = `کد تایید شما: ${code}`;
|
const brand = businessNameFa?.trim();
|
||||||
|
const message = brand
|
||||||
|
? `کد تایید شما: ${code}\n${brand}`
|
||||||
|
: `کد تایید شما: ${code}`;
|
||||||
return this.sendMessage(cellNumber, message);
|
return this.sendMessage(cellNumber, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +65,85 @@ export class SmsService {
|
|||||||
throw new BadRequestException('Invalid cellphone number');
|
throw new BadRequestException('Invalid cellphone number');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isProxyConfigured()) {
|
||||||
|
return this.sendViaProxy(destination, text);
|
||||||
|
}
|
||||||
|
|
||||||
return this.sendQuick(destination, text);
|
return this.sendQuick(destination, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isProxyConfigured(): boolean {
|
||||||
|
const url = this.config.get<string>('SMS_PROXY_URL')?.trim();
|
||||||
|
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')?.trim();
|
||||||
|
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')?.trim();
|
||||||
|
return Boolean(url && apiKey && domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */
|
||||||
|
private async sendViaProxy(destination: string, message: string): Promise<SmsSendResult> {
|
||||||
|
const url = this.config.get<string>('SMS_PROXY_URL')!.trim();
|
||||||
|
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
|
||||||
|
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')!.trim();
|
||||||
|
const masked = maskMsisdn(destination);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain,
|
||||||
|
to: destination,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`SMS proxy network error for ${masked}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('SMS provider is unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: { success?: boolean; serverId?: string; message?: string } = {};
|
||||||
|
try {
|
||||||
|
payload = (await response.json()) as typeof payload;
|
||||||
|
} catch {
|
||||||
|
this.logger.error(`SMS proxy returned non-JSON for ${masked} (HTTP ${response.status})`);
|
||||||
|
throw new ServiceUnavailableException('SMS provider returned an invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.success) {
|
||||||
|
const providerMessage =
|
||||||
|
typeof payload.message === 'string' && payload.message.trim()
|
||||||
|
? payload.message.trim()
|
||||||
|
: `HTTP ${response.status}`;
|
||||||
|
this.logger.warn(`SMS proxy failed for ${masked}: ${providerMessage}`);
|
||||||
|
if (response.status === 400) {
|
||||||
|
throw new BadRequestException(providerMessage || 'Invalid SMS destination or sender');
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new ServiceUnavailableException('SMS provider authentication failed');
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
providerMessage || 'SMS provider rejected the request',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = String(payload.serverId ?? '');
|
||||||
|
if (!serverId) {
|
||||||
|
this.logger.warn(`SMS proxy succeeded without serverId for ${masked}`);
|
||||||
|
throw new ServiceUnavailableException('SMS provider returned an empty server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`SMS sent to ${masked} via proxy (serverId=${serverId})`);
|
||||||
|
return { serverId };
|
||||||
|
}
|
||||||
|
|
||||||
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
|
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
|
||||||
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
||||||
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export class DomainAdminController {
|
|||||||
return this.service.syncSsl(user);
|
return this.service.syncSsl(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Issue Let's Encrypt for storefront domains missing SSL (websites VM). */
|
||||||
|
@Post('website-ssl')
|
||||||
|
@HttpCode(200)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
issueWebsiteSsl(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.issueWebsiteSsl(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':domainId/deploy')
|
@Post(':domainId/deploy')
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -43,6 +51,13 @@ export class DomainAdminController {
|
|||||||
return this.service.deploy(domainId, user);
|
return this.service.deploy(domainId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':domainId/issue-ssl')
|
||||||
|
@HttpCode(200)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
issueSsl(@Param('domainId') domainId: string, @CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.issueSsl(domainId, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':domainId')
|
@Patch(':domainId')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { probeTlsHost } from '../common/tls-probe';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||||
import { DisableDomainDto } from './dto/disable-domain.dto';
|
import { DisableDomainDto } from './dto/disable-domain.dto';
|
||||||
@@ -125,7 +126,118 @@ export class DomainAdminService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'accepted' as const,
|
status: 'accepted' as const,
|
||||||
message: 'SSL sync started on dashboards server',
|
message: 'Dashboard SSL sync started (manage / business.* / customer.*)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue Let's Encrypt certs on the websites VM for storefront domains
|
||||||
|
* that have deploy_slug and currently report ssl_enabled=false.
|
||||||
|
*/
|
||||||
|
async issueWebsiteSsl(actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
|
const targets = await this.prisma.domain.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
sslEnabled: false,
|
||||||
|
deploySlug: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true, host: true, deploySlug: true },
|
||||||
|
orderBy: { host: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'ok' as const,
|
||||||
|
message: 'No storefront domains need SSL',
|
||||||
|
issued: [] as string[],
|
||||||
|
failed: [] as Array<{ host: string; error: string }>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const issued: string[] = [];
|
||||||
|
const failed: Array<{ host: string; error: string }> = [];
|
||||||
|
|
||||||
|
for (const domain of targets) {
|
||||||
|
try {
|
||||||
|
await this.websiteDeployAgent.issueSsl({
|
||||||
|
host: domain.host,
|
||||||
|
slug: domain.deploySlug,
|
||||||
|
});
|
||||||
|
const ok = await probeTlsHost(domain.host);
|
||||||
|
await this.prisma.domain.update({
|
||||||
|
where: { id: domain.id },
|
||||||
|
data: { sslEnabled: ok },
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
issued.push(domain.host);
|
||||||
|
} else {
|
||||||
|
failed.push({
|
||||||
|
host: domain.host,
|
||||||
|
error: 'Certbot finished but TLS probe still failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failed.push({
|
||||||
|
host: domain.host,
|
||||||
|
error: err instanceof Error ? err.message : 'SSL issue failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
failed.length === 0
|
||||||
|
? `Issued SSL for ${issued.length} website(s)`
|
||||||
|
: `Issued ${issued.length}, failed ${failed.length} website SSL`;
|
||||||
|
|
||||||
|
return { status: 'ok' as const, message, issued, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async issueSsl(domainIdRaw: string, actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
|
const domainId = BigInt(domainIdRaw);
|
||||||
|
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
|
||||||
|
if (!domain) {
|
||||||
|
throw new NotFoundException('Domain not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = domain.deploySlug?.trim() || null;
|
||||||
|
if (!slug) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This domain has no storefront deploy configured — use Sync dashboard SSL for business/customer hosts',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.websiteDeployAgent.issueSsl({ host: domain.host, slug });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceUnavailableException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
err instanceof Error ? err.message : 'SSL issue failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await probeTlsHost(domain.host);
|
||||||
|
const updated = await this.prisma.domain.update({
|
||||||
|
where: { id: domainId },
|
||||||
|
data: { sslEnabled: ok },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Certbot ran for ${domain.host} but HTTPS probe failed — check DNS for apex and www`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'issued' as const,
|
||||||
|
host: domain.host,
|
||||||
|
sslEnabled: updated.sslEnabled,
|
||||||
|
message: `SSL issued for ${domain.host}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { provisionUrlFromDeployUrl } from './website-deploy.util';
|
import { provisionUrlFromDeployUrl, sslUrlFromDeployUrl } from './website-deploy.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WebsiteDeployAgentService {
|
export class WebsiteDeployAgentService {
|
||||||
@@ -73,4 +73,43 @@ export class WebsiteDeployAgentService {
|
|||||||
|
|
||||||
return response.json().catch(() => ({ status: 'accepted', slug }));
|
return response.json().catch(() => ({ status: 'accepted', slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Issue / renew Let's Encrypt cert for a storefront host on the websites VM. */
|
||||||
|
async issueSsl(input: { host: string; slug?: string | null }) {
|
||||||
|
const { deployUrl, token } = this.credentials();
|
||||||
|
const url = sslUrlFromDeployUrl(deployUrl);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Deploy-Token': token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
host: input.host,
|
||||||
|
...(input.slug ? { slug: input.slug } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('Could not reach website deploy agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
let detail = text;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { detail?: string; error?: string };
|
||||||
|
detail = parsed.detail || parsed.error || text;
|
||||||
|
} catch {
|
||||||
|
/* keep raw */
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`SSL agent rejected request (${response.status})${detail ? `: ${detail}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json().catch(() => ({ status: 'issued', host: input.host }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,3 +39,12 @@ export function provisionUrlFromDeployUrl(deployUrl: string): string {
|
|||||||
}
|
}
|
||||||
return `${trimmed}/provision`;
|
return `${trimmed}/provision`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Turn .../deploy into .../ssl (or append /ssl if bare). */
|
||||||
|
export function sslUrlFromDeployUrl(deployUrl: string): string {
|
||||||
|
const trimmed = deployUrl.trim().replace(/\/+$/, '');
|
||||||
|
if (/\/deploy$/i.test(trimmed)) {
|
||||||
|
return trimmed.replace(/\/deploy$/i, '/ssl');
|
||||||
|
}
|
||||||
|
return `${trimmed}/ssl`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -641,7 +641,13 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["cellNumber"],
|
"required": ["cellNumber"],
|
||||||
"properties": { "cellNumber": { "type": "string" } }
|
"properties": {
|
||||||
|
"cellNumber": { "type": "string" },
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Tenant host/apex used to brand the OTP SMS with the business Farsi name"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user