diff --git a/package-lock.json b/package-lock.json index e0c9505..6fdfe56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.1.3", "@prisma/client": "^6.0.0", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", @@ -1529,6 +1530,19 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -1942,6 +1956,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.3.tgz", + "integrity": "sha512-pE7BSbKHiojpl4v7iEzdfFLXXJaH5RPJxI3Wr4x3HU59l83PJfhcUx4mGPWq245+DCAENAzRF/+ZoYr2tJCe/g==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3112,6 +3132,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4454,6 +4491,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/package.json b/package.json index 64ac535..f79bcb9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.1.3", "@prisma/client": "^6.0.0", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", diff --git a/src/app.module.ts b/src/app.module.ts index ead6248..bc65f3a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index f5d76b7..7416d7b 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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, diff --git a/src/business-admin/dto/create-business.dto.ts b/src/business-admin/dto/create-business.dto.ts index 42a7769..653d66e 100644 --- a/src/business-admin/dto/create-business.dto.ts +++ b/src/business-admin/dto/create-business.dto.ts @@ -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)', diff --git a/src/common/tls-probe.ts b/src/common/tls-probe.ts new file mode 100644 index 0000000..b1adb84 --- /dev/null +++ b/src/common/tls-probe.ts @@ -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 { + 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, + chunkSize = 10, +): Promise> { + const unique = [ + ...new Set( + hosts + .map((h) => h?.trim().toLowerCase()) + .filter((h): h is string => !!h), + ), + ]; + + const results = new Map(); + 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; +} diff --git a/src/internal-ssl/internal-ssl.controller.ts b/src/internal-ssl/internal-ssl.controller.ts index 12cf1c0..4618ac7 100644 --- a/src/internal-ssl/internal-ssl.controller.ts +++ b/src/internal-ssl/internal-ssl.controller.ts @@ -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'); + } } diff --git a/src/internal-ssl/internal-ssl.service.ts b/src/internal-ssl/internal-ssl.service.ts index 3b9309b..1885876 100644 --- a/src/internal-ssl/internal-ssl.service.ts +++ b/src/internal-ssl/internal-ssl.service.ts @@ -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 { - 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> { + 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 { + 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; + } + } }