diff --git a/src/auth/remote-sms-auth.guard.ts b/src/auth/remote-sms-auth.guard.ts index b2e6f28..30e7683 100644 --- a/src/auth/remote-sms-auth.guard.ts +++ b/src/auth/remote-sms-auth.guard.ts @@ -7,6 +7,10 @@ import { import { ConfigService } from '@nestjs/config'; import { createHmac, timingSafeEqual } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; +import { + findTenantForHost, + normalizeTenantHost, +} from './tenant-host.util'; const MAX_SKEW_SECONDS = 300; @@ -88,7 +92,7 @@ export class RemoteSmsAuthGuard implements CanActivate { throw new UnauthorizedException('timestamp منقضی است'); } - const domain = this.normalizeHost(domainRaw); + const domain = normalizeTenantHost(domainRaw); if (!domain) { throw new UnauthorizedException('دامنه نامعتبر است'); } @@ -138,7 +142,7 @@ export class RemoteSmsAuthGuard implements CanActivate { } private async findTenantByDomain(host: string) { - const normalizedHost = this.normalizeHost(host); + const normalizedHost = normalizeTenantHost(host); if (!normalizedHost) return null; const tenants = await this.prisma.tenant.findMany({ @@ -156,89 +160,18 @@ export class RemoteSmsAuthGuard implements CanActivate { }, }); - let best: { row: TenantHostRow; score: number } | null = null; - for (const row of tenants) { - const score = this.scoreHostMatch(row, normalizedHost); - if (score <= 0) continue; - if (!best || score > best.score) { - best = { row, score }; - } - } - if (!best) return null; + const hit = findTenantForHost(tenants as TenantHostRow[], normalizedHost); + if (!hit) return null; return { - id: best.row.id, - nameFa: best.row.nameFa, - domain: best.row.domain, - product: best.row.product, - isActive: best.row.isActive, + id: hit.id, + nameFa: hit.nameFa, + domain: hit.domain, + product: hit.product, + isActive: hit.isActive, }; } - /** - * امتیاز بالاتر = هویت دقیق‌تر نسخه. - * nodeServer ضعیف است چون چند نسخه ممکن است یک API مشترک داشته باشند. - */ - private scoreHostMatch(tenant: TenantHostRow, host: string): number { - const hostNoWww = host.replace(/^www\./, ''); - const eq = (raw?: string | null) => { - const normalized = this.normalizeHost(raw); - if (!normalized) return false; - return ( - normalized === host || normalized.replace(/^www\./, '') === hostNoWww - ); - }; - - if (eq(tenant.domain)) return 100; - - for (const alias of this.readDomains(tenant.domains)) { - if (eq(alias)) return 90; - } - - if (eq(tenant.appUrl)) return 80; - - // adminUrl گاهی موبایل است؛ فقط اگر شبیه میزبان باشد - if (this.looksLikeHostname(tenant.adminUrl) && eq(tenant.adminUrl)) { - return 50; - } - - if (eq(tenant.nodeServer)) return 10; - - return 0; - } - - private looksLikeHostname(value?: string | null) { - const normalized = this.normalizeHost(value); - if (!normalized || !normalized.includes('.')) return false; - if (/^\d+(\.\d+)*$/.test(normalized)) return false; - if (/^09\d{9}$/.test(normalized)) return false; - return true; - } - - private readDomains(raw: unknown): string[] { - if (!raw) return []; - if (Array.isArray(raw)) return raw.map(String); - if (typeof raw === 'string') { - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.map(String) : []; - } catch { - return []; - } - } - return []; - } - - private normalizeHost(value?: string | null) { - if (!value) return ''; - return String(value) - .trim() - .toLowerCase() - .replace(/^https?:\/\//, '') - .split('/')[0] - .split(':')[0]; - } - private equalHex(a: string, b: string) { try { const left = Buffer.from(String(a).toLowerCase(), 'utf8'); diff --git a/src/auth/tenant-host.util.ts b/src/auth/tenant-host.util.ts new file mode 100644 index 0000000..96ac3c4 --- /dev/null +++ b/src/auth/tenant-host.util.ts @@ -0,0 +1,101 @@ +export type TenantHostFields = { + domain?: string | null; + domains?: unknown; + adminUrl?: string | null; + appUrl?: string | null; + nodeServer?: string | null; +}; + +export function normalizeTenantHost(value?: string | null): string { + if (!value) return ''; + return String(value) + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .split('/')[0] + .split(':')[0] + .replace(/\.$/, ''); +} + +/** aliases JSON array, object map, CSV, or a single host/URL */ +export function readHostList(raw: unknown): string[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw.flatMap((item) => readHostList(item)); + if (typeof raw === 'object') { + return Object.values(raw as Record).flatMap(readHostList); + } + const text = String(raw).trim(); + if (!text) return []; + if ( + (text.startsWith('[') && text.endsWith(']')) || + (text.startsWith('{') && text.endsWith('}')) + ) { + try { + return readHostList(JSON.parse(text)); + } catch { + /* fall through */ + } + } + return text + .split(/[\s,;]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function looksLikeHostname(value: string): boolean { + if (!value.includes('.')) return false; + if (/^\d+(\.\d+)*$/.test(value)) return false; + if (/^09\d{9}$/.test(value)) return false; + return true; +} + +function hostsEqual(left: string, right: string): boolean { + if (!left || !right) return false; + if (left === right) return true; + return left.replace(/^www\./, '') === right.replace(/^www\./, ''); +} + +/** + * Same identity as GET /get_user?domain= — primary domain, then aliases, + * then app/admin/node URLs so HMAC and get_user cannot disagree. + */ +export function scoreTenantHost( + tenant: TenantHostFields, + hostRaw: string, +): number { + const host = normalizeTenantHost(hostRaw); + if (!host) return 0; + const eq = (raw?: string | null) => + hostsEqual(normalizeTenantHost(raw), host); + + if (eq(tenant.domain)) return 100; + for (const alias of readHostList(tenant.domains)) { + if (eq(alias)) return 90; + } + for (const value of readHostList(tenant.appUrl)) { + if (eq(value)) return 80; + } + for (const value of readHostList(tenant.adminUrl)) { + const normalized = normalizeTenantHost(value); + if (looksLikeHostname(normalized) && hostsEqual(normalized, host)) { + return 50; + } + } + for (const value of readHostList(tenant.nodeServer)) { + if (eq(value)) return 10; + } + return 0; +} + +export function findTenantForHost( + tenants: T[], + host: string, +): T | undefined { + let best: { row: T; score: number } | undefined; + for (const row of tenants) { + const score = scoreTenantHost(row, host); + if (score <= 0) continue; + if (!best || score > best.score) best = { row, score }; + } + return best?.row; +} diff --git a/src/get-user/get-user.service.ts b/src/get-user/get-user.service.ts index 77ce18e..7ab890b 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -4,6 +4,10 @@ import { PrismaService } from '../prisma/prisma.service'; import { applyKhanegiNavSample } from './khanegi-nav-sample'; import { rewriteLegacyUploadUrl } from '../uploads/uploads.util'; import { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys'; +import { + findTenantForHost as matchTenantForHost, + normalizeTenantHost, +} from '../auth/tenant-host.util'; type UiSetting = { key: string; value: string }; @@ -28,7 +32,7 @@ export class GetUserService { constructor(private readonly prisma: PrismaService) {} async resolveByHost(hostHeader?: string, domainQuery?: string) { - const host = this.normalizeHost(domainQuery || hostHeader || ''); + const host = normalizeTenantHost(domainQuery || hostHeader || ''); if (!host) { throw new NotFoundException('domain required'); } @@ -374,75 +378,12 @@ export class GetUserService { } /** - * اول دامنهٔ اصلی (Tenant.domain)، بعد aliases داخل domains[]. - * اگر چند نسخه یک دامنه را داشته باشند، نسخهٔ primary برنده است. + * همان منطق HMAC ریموت: دامنهٔ اصلی، aliases، بعد admin/app/node URL. */ - private findTenantForHost< - T extends { - domain: string; - domains: unknown; - }, - >(tenants: T[], host: string): T | undefined { - const normalizedHost = this.normalizeHost(host); - const hostNoWww = normalizedHost.replace(/^www\./, ''); - - const primary = tenants.find((item) => { - const d = this.normalizeHost(item.domain); - return d === normalizedHost || d.replace(/^www\./, '') === hostNoWww; - }); - if (primary) return primary; - - return tenants.find((item) => this.matchesAliasHost(item, normalizedHost)); - } - - private matchesAliasHost( - tenant: { - domain: string; - domains: unknown; - }, + private findTenantForHost( + tenants: T[], host: string, - ) { - const hostNoWww = host.replace(/^www\./, ''); - for (const value of this.readDomains(tenant.domains)) { - const normalized = this.normalizeHost(value); - if (!normalized) continue; - if (normalized === host || normalized.replace(/^www\./, '') === hostNoWww) { - return true; - } - } - return false; - } - - private matchesHost( - tenant: { - domain: string; - domains: unknown; - }, - host: string, - ) { - return Boolean(this.findTenantForHost([tenant], host)); - } - - private readDomains(raw: unknown): string[] { - if (!raw) return []; - if (Array.isArray(raw)) return raw.map(String); - if (typeof raw === 'string') { - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.map(String) : []; - } catch { - return []; - } - } - return []; - } - - private normalizeHost(value: string) { - return value - .trim() - .toLowerCase() - .replace(/^https?:\/\//, '') - .split('/')[0] - .split(':')[0]; + ): T | undefined { + return matchTenantForHost(tenants, host); } }