Files
api/src/auth/tenant-host.util.ts
T

126 lines
3.7 KiB
TypeScript
Raw Normal View History

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<string, unknown>).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 (/^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\./, '');
}
/** new.gholaktala.ir belongs to tenant domain gholaktala.ir — not via shared nodeServer. */
function isSubdomainOf(host: string, parent: string): boolean {
const child = normalizeTenantHost(host);
const root = normalizeTenantHost(parent).replace(/^www\./, '');
if (!child || !root || !root.includes('.')) return false;
return child === root || child.endsWith('.' + root);
}
export type TenantHostMatchMode = 'full' | 'hmac';
/**
* full: get_user — دامنه، alias، app/admin/node.
* hmac: فقط دامنهٔ اصلی نسخه (و ساب‌دامنهٔ همان). adminUrl مشترک
* (k.montzar.ir روی نسخهٔ app.gholaktala.ir) هویت نیست.
*/
export function scoreTenantHost(
tenant: TenantHostFields,
hostRaw: string,
mode: TenantHostMatchMode = 'full',
): number {
const host = normalizeTenantHost(hostRaw);
if (!host) return 0;
const eq = (raw?: string | null) =>
hostsEqual(normalizeTenantHost(raw), host);
if (eq(tenant.domain)) return 100;
if (mode === 'hmac') {
if (tenant.domain && isSubdomainOf(host, tenant.domain)) return 75;
return 0;
}
for (const alias of readHostList(tenant.domains)) {
if (eq(alias)) return 90;
}
for (const value of readHostList(tenant.appUrl)) {
if (eq(value)) return 80;
}
if (tenant.domain && isSubdomainOf(host, tenant.domain)) return 75;
for (const alias of readHostList(tenant.domains)) {
if (isSubdomainOf(host, alias)) return 72;
}
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<T extends TenantHostFields>(
tenants: T[],
host: string,
mode: TenantHostMatchMode = 'full',
): T | undefined {
const scored = tenants
.map((row) => ({ row, score: scoreTenantHost(row, host, mode) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
if (!scored.length) return undefined;
const best = scored[0].score;
const tied = scored.filter((item) => item.score === best);
if (tied.length !== 1) return undefined;
return tied[0].row;
}