Sync domain SSL status on a 2-hour cron and capture owner FA/EN names.

List pages stay cheap by reading ssl_enabled while Nest probes apex hosts in the background; create-business now stores first/last name in both locales.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-03 09:21:06 +03:30
co-authored by Cursor
parent f233665d13
commit 267a218c26
8 changed files with 231 additions and 8 deletions
+2
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './auth/auth.module';
@@ -37,6 +38,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
PrismaModule,
RedisModule,
LegacyMysqlModule,
+16 -2
View File
@@ -42,6 +42,7 @@ type BusinessRow = {
sslEnabled: boolean | null;
ownerUserId: bigint | null;
ownerName: string | null;
ownerNameEn: string | null;
ownerCellNumber: string | null;
primaryColor: string | null;
defaultLocale: string | null;
@@ -117,6 +118,7 @@ export class BusinessAdminService {
dom.ssl_enabled AS "sslEnabled",
own."ownerUserId" AS "ownerUserId",
own."ownerName" AS "ownerName",
own."ownerNameEn" AS "ownerNameEn",
own."ownerCellNumber" AS "ownerCellNumber",
b.settings->'branding'->>'primaryColor' AS "primaryColor",
b.settings->'branding'->>'defaultLocale' AS "defaultLocale"
@@ -131,7 +133,8 @@ export class BusinessAdminService {
LEFT JOIN LATERAL (
SELECT
u.id AS "ownerUserId",
(u.first_name || ' ' || u.last_name) AS "ownerName",
NULLIF(TRIM(BOTH ' ' FROM COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), '') AS "ownerName",
NULLIF(TRIM(BOTH ' ' FROM COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')), '') AS "ownerNameEn",
u.cell_number AS "ownerCellNumber"
FROM business_users bu
JOIN users u ON u.id = bu.user_id
@@ -823,7 +826,12 @@ export class BusinessAdminService {
tx: Prisma.TransactionClient,
dto: Pick<
CreateBusinessDto,
'ownerFirstName' | 'ownerLastName' | 'ownerCellNumber' | 'ownerPassword'
| 'ownerFirstName'
| 'ownerLastName'
| 'ownerFirstNameEn'
| 'ownerLastNameEn'
| 'ownerCellNumber'
| 'ownerPassword'
>,
) {
const existing = await tx.user.findUnique({
@@ -847,6 +855,8 @@ export class BusinessAdminService {
passwordHash,
firstName: dto.ownerFirstName.trim(),
lastName: dto.ownerLastName.trim(),
firstNameEn: dto.ownerFirstNameEn.trim(),
lastNameEn: dto.ownerLastNameEn.trim(),
cellVerifiedAt: new Date(),
},
});
@@ -933,6 +943,8 @@ export class BusinessAdminService {
cellNumber: string;
firstName: string | null;
lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
email: string | null;
};
}[];
@@ -971,6 +983,8 @@ export class BusinessAdminService {
cellNumber: owner.cellNumber,
firstName: owner.firstName,
lastName: owner.lastName,
firstNameEn: owner.firstNameEn,
lastNameEn: owner.lastNameEn,
email: owner.email,
}
: null,
@@ -44,6 +44,14 @@ export class CreateBusinessDto {
@MinLength(2)
ownerLastName!: string;
@IsString()
@MinLength(2)
ownerFirstNameEn!: string;
@IsString()
@MinLength(2)
ownerLastNameEn!: string;
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'ownerCellNumber must be in E.164 format (e.g. +989121234567)',
+84
View File
@@ -0,0 +1,84 @@
import * as tls from 'tls';
const DEFAULT_TIMEOUT_MS = 2500;
/**
* Live TLS check: connect to host:443 with SNI and require a trusted cert.
* Returns false on timeout, DNS failure, or cert/hostname errors.
*/
export function probeTlsHost(
hostRaw: string,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<boolean> {
const host = hostRaw.trim().toLowerCase();
if (!host || host.includes(':') || host.endsWith('.local')) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
let socket: tls.TLSSocket;
try {
socket = tls.connect(
{
host,
port: 443,
servername: host,
rejectUnauthorized: true,
timeout: timeoutMs,
},
() => {
const ok = socket.authorized === true;
socket.destroy();
finish(ok);
},
);
} catch {
finish(false);
return;
}
socket.on('error', () => {
socket.destroy();
finish(false);
});
socket.on('timeout', () => {
socket.destroy();
finish(false);
});
});
}
/** Probe many hosts in parallel (chunked); returns Map host → ssl ok. */
export async function probeTlsHosts(
hosts: Array<string | null | undefined>,
chunkSize = 10,
): Promise<Map<string, boolean>> {
const unique = [
...new Set(
hosts
.map((h) => h?.trim().toLowerCase())
.filter((h): h is string => !!h),
),
];
const results = new Map<string, boolean>();
for (let i = 0; i < unique.length; i += chunkSize) {
const chunk = unique.slice(i, i + chunkSize);
const probed = await Promise.all(
chunk.map(async (host) => [host, await probeTlsHost(host)] as const),
);
for (const [host, ok] of probed) {
results.set(host, ok);
}
}
return results;
}
+7 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { InternalSslService } from './internal-ssl.service';
import { SslSyncTokenGuard } from './ssl-sync-token.guard';
@@ -18,4 +18,10 @@ export class InternalSslController {
listApiHosts() {
return this.service.listApiHosts();
}
/** Manually probe apex hosts and sync domains.ssl_enabled. */
@Post('refresh-status')
refreshStatus() {
return this.service.refreshSslStatuses('manual');
}
}
+67 -5
View File
@@ -1,21 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { probeTlsHosts } from '../common/tls-probe';
@Injectable()
export class InternalSslService {
export class InternalSslService implements OnModuleInit {
private readonly logger = new Logger(InternalSslService.name);
private refreshRunning = false;
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
private async listActiveApexHosts(): Promise<string[]> {
const domains = await this.prisma.domain.findMany({
onModuleInit() {
// Warm ssl_enabled shortly after boot so lists are accurate without waiting for cron.
setTimeout(() => {
void this.refreshSslStatuses('startup');
}, 5_000);
}
private async listActiveDomains(): Promise<Array<{ id: bigint; host: string; sslEnabled: boolean }>> {
return this.prisma.domain.findMany({
where: { isActive: true },
select: { host: true },
select: { id: true, host: true, sslEnabled: true },
orderBy: { host: 'asc' },
});
}
private async listActiveApexHosts(): Promise<string[]> {
const domains = await this.listActiveDomains();
const hosts: string[] = [];
for (const { host } of domains) {
const apex = host.trim().toLowerCase();
@@ -53,4 +68,51 @@ export class InternalSslService {
return { hosts: [...hosts].sort() };
}
/**
* Probe each active apex host and sync domains.ssl_enabled to the live result.
* Runs every 2 hours (and once shortly after API boot).
*/
@Cron(CronExpression.EVERY_2_HOURS)
async refreshSslStatusesCron() {
await this.refreshSslStatuses('cron');
}
async refreshSslStatuses(reason: 'cron' | 'startup' | 'manual' = 'manual') {
if (this.refreshRunning) {
this.logger.warn(`SSL status refresh skipped (${reason}): already running`);
return { updated: 0, checked: 0, skipped: true as const };
}
this.refreshRunning = true;
const started = Date.now();
try {
const domains = await this.listActiveDomains();
const sslByHost = await probeTlsHosts(domains.map((d) => d.host));
let updated = 0;
for (const domain of domains) {
const hostKey = domain.host.trim().toLowerCase();
const sslEnabled = sslByHost.get(hostKey) ?? false;
if (sslEnabled === domain.sslEnabled) continue;
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled },
});
updated += 1;
this.logger.log(
`SSL status ${domain.host}: ${domain.sslEnabled}${sslEnabled}`,
);
}
this.logger.log(
`SSL status refresh (${reason}): checked=${domains.length} updated=${updated} in ${Date.now() - started}ms`,
);
return { updated, checked: domains.length, skipped: false as const };
} finally {
this.refreshRunning = false;
}
}
}