mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
f233665d13
commit
267a218c26
Generated
+46
@@ -15,6 +15,7 @@
|
|||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/passport": "^11.0.0",
|
"@nestjs/passport": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -1529,6 +1530,19 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@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": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -1942,6 +1956,12 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -3112,6 +3132,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -4454,6 +4491,15 @@
|
|||||||
"url": "https://github.com/sponsors/wellwelwel"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/passport": "^11.0.0",
|
"@nestjs/passport": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { RedisModule } from './redis/redis.module';
|
import { RedisModule } from './redis/redis.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
@@ -37,6 +38,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
LegacyMysqlModule,
|
LegacyMysqlModule,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type BusinessRow = {
|
|||||||
sslEnabled: boolean | null;
|
sslEnabled: boolean | null;
|
||||||
ownerUserId: bigint | null;
|
ownerUserId: bigint | null;
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
|
ownerNameEn: string | null;
|
||||||
ownerCellNumber: string | null;
|
ownerCellNumber: string | null;
|
||||||
primaryColor: string | null;
|
primaryColor: string | null;
|
||||||
defaultLocale: string | null;
|
defaultLocale: string | null;
|
||||||
@@ -117,6 +118,7 @@ export class BusinessAdminService {
|
|||||||
dom.ssl_enabled AS "sslEnabled",
|
dom.ssl_enabled AS "sslEnabled",
|
||||||
own."ownerUserId" AS "ownerUserId",
|
own."ownerUserId" AS "ownerUserId",
|
||||||
own."ownerName" AS "ownerName",
|
own."ownerName" AS "ownerName",
|
||||||
|
own."ownerNameEn" AS "ownerNameEn",
|
||||||
own."ownerCellNumber" AS "ownerCellNumber",
|
own."ownerCellNumber" AS "ownerCellNumber",
|
||||||
b.settings->'branding'->>'primaryColor' AS "primaryColor",
|
b.settings->'branding'->>'primaryColor' AS "primaryColor",
|
||||||
b.settings->'branding'->>'defaultLocale' AS "defaultLocale"
|
b.settings->'branding'->>'defaultLocale' AS "defaultLocale"
|
||||||
@@ -131,7 +133,8 @@ export class BusinessAdminService {
|
|||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
u.id AS "ownerUserId",
|
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"
|
u.cell_number AS "ownerCellNumber"
|
||||||
FROM business_users bu
|
FROM business_users bu
|
||||||
JOIN users u ON u.id = bu.user_id
|
JOIN users u ON u.id = bu.user_id
|
||||||
@@ -823,7 +826,12 @@ export class BusinessAdminService {
|
|||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
dto: Pick<
|
dto: Pick<
|
||||||
CreateBusinessDto,
|
CreateBusinessDto,
|
||||||
'ownerFirstName' | 'ownerLastName' | 'ownerCellNumber' | 'ownerPassword'
|
| 'ownerFirstName'
|
||||||
|
| 'ownerLastName'
|
||||||
|
| 'ownerFirstNameEn'
|
||||||
|
| 'ownerLastNameEn'
|
||||||
|
| 'ownerCellNumber'
|
||||||
|
| 'ownerPassword'
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
const existing = await tx.user.findUnique({
|
const existing = await tx.user.findUnique({
|
||||||
@@ -847,6 +855,8 @@ export class BusinessAdminService {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
firstName: dto.ownerFirstName.trim(),
|
firstName: dto.ownerFirstName.trim(),
|
||||||
lastName: dto.ownerLastName.trim(),
|
lastName: dto.ownerLastName.trim(),
|
||||||
|
firstNameEn: dto.ownerFirstNameEn.trim(),
|
||||||
|
lastNameEn: dto.ownerLastNameEn.trim(),
|
||||||
cellVerifiedAt: new Date(),
|
cellVerifiedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -933,6 +943,8 @@ export class BusinessAdminService {
|
|||||||
cellNumber: string;
|
cellNumber: string;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
|
firstNameEn: string | null;
|
||||||
|
lastNameEn: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
@@ -971,6 +983,8 @@ export class BusinessAdminService {
|
|||||||
cellNumber: owner.cellNumber,
|
cellNumber: owner.cellNumber,
|
||||||
firstName: owner.firstName,
|
firstName: owner.firstName,
|
||||||
lastName: owner.lastName,
|
lastName: owner.lastName,
|
||||||
|
firstNameEn: owner.firstNameEn,
|
||||||
|
lastNameEn: owner.lastNameEn,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export class CreateBusinessDto {
|
|||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
ownerLastName!: string;
|
ownerLastName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
ownerFirstNameEn!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
ownerLastNameEn!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||||
message: 'ownerCellNumber must be in E.164 format (e.g. +989121234567)',
|
message: 'ownerCellNumber must be in E.164 format (e.g. +989121234567)',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { InternalSslService } from './internal-ssl.service';
|
||||||
import { SslSyncTokenGuard } from './ssl-sync-token.guard';
|
import { SslSyncTokenGuard } from './ssl-sync-token.guard';
|
||||||
|
|
||||||
@@ -18,4 +18,10 @@ export class InternalSslController {
|
|||||||
listApiHosts() {
|
listApiHosts() {
|
||||||
return this.service.listApiHosts();
|
return this.service.listApiHosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Manually probe apex hosts and sync domains.ssl_enabled. */
|
||||||
|
@Post('refresh-status')
|
||||||
|
refreshStatus() {
|
||||||
|
return this.service.refreshSslStatuses('manual');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,36 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { probeTlsHosts } from '../common/tls-probe';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InternalSslService {
|
export class InternalSslService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(InternalSslService.name);
|
||||||
|
private refreshRunning = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async listActiveApexHosts(): Promise<string[]> {
|
onModuleInit() {
|
||||||
const domains = await this.prisma.domain.findMany({
|
// 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 },
|
where: { isActive: true },
|
||||||
select: { host: true },
|
select: { id: true, host: true, sslEnabled: true },
|
||||||
orderBy: { host: 'asc' },
|
orderBy: { host: 'asc' },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listActiveApexHosts(): Promise<string[]> {
|
||||||
|
const domains = await this.listActiveDomains();
|
||||||
const hosts: string[] = [];
|
const hosts: string[] = [];
|
||||||
for (const { host } of domains) {
|
for (const { host } of domains) {
|
||||||
const apex = host.trim().toLowerCase();
|
const apex = host.trim().toLowerCase();
|
||||||
@@ -53,4 +68,51 @@ export class InternalSslService {
|
|||||||
|
|
||||||
return { hosts: [...hosts].sort() };
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user