diff --git a/src/settings-proxy/settings-proxy.service.ts b/src/settings-proxy/settings-proxy.service.ts index 1e8c91a..54ce0e3 100644 --- a/src/settings-proxy/settings-proxy.service.ts +++ b/src/settings-proxy/settings-proxy.service.ts @@ -3,26 +3,32 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import axios from 'axios'; +import { createHmac } from 'crypto'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; +const SUPER_SETTINGS_CANONICAL_PATH = '/api/super/settings'; + @Injectable() export class SettingsProxyService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, + private readonly config: ConfigService, ) {} async getSettings(tenantId: string) { const tenant = await this.requireTenant(tenantId); + const auth = this.buildAuthHeaders(tenant, 'GET'); - if (!tenant.remoteApiToken) { + if (!auth) { return { stub: true, message: - 'remoteApiToken تنظیم نشده. فعلاً stub است تا endpoint سوپر روی Laravel اضافه شود.', + 'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ push ممکن نیست.', tenant: { id: tenant.id, slug: tenant.slug, @@ -35,7 +41,7 @@ export class SettingsProxyService { try { const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), { headers: { - Authorization: `Bearer ${tenant.remoteApiToken}`, + ...auth, Accept: 'application/json', }, timeout: 15000, @@ -54,8 +60,9 @@ export class SettingsProxyService { userId?: string, ) { const tenant = await this.requireTenant(tenantId); + const auth = this.buildAuthHeaders(tenant, 'POST'); - if (!tenant.remoteApiToken) { + if (!auth) { await this.audit.log({ action: 'settings.proxy.stub_update', entityType: 'tenant_settings', @@ -69,7 +76,7 @@ export class SettingsProxyService { return { stub: true, message: - 'remoteApiToken تنظیم نشده؛ تغییر فقط در audit ثبت شد (stub).', + 'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ تغییر فقط در audit ثبت شد (stub).', accepted: payload, }; } @@ -80,7 +87,7 @@ export class SettingsProxyService { payload, { headers: { - Authorization: `Bearer ${tenant.remoteApiToken}`, + ...auth, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -95,6 +102,9 @@ export class SettingsProxyService { tenantId, userId, after: payload as Prisma.InputJsonValue, + meta: { + authMode: auth.Authorization ? 'bearer' : 'hmac', + } as Prisma.InputJsonValue, }); return { stub: false, result: data }; @@ -105,6 +115,89 @@ export class SettingsProxyService { } } + /** + * Prefer per-tenant Bearer; else same shared HMAC used by SMS remote. + * Canonical path is always /api/super/settings so product middleware can verify + * regardless of /server prefix on nodeServer. + */ + private buildAuthHeaders( + tenant: { + remoteApiToken: string | null; + domain: string | null; + domains: unknown; + nodeServer: string | null; + }, + method: 'GET' | 'POST', + ): Record | null { + const token = String(tenant.remoteApiToken || '').trim(); + if (token) { + return { Authorization: `Bearer ${token}` }; + } + + const shared = String( + this.config.get('SMS_REMOTE_SHARED_SECRET') || '', + ).trim(); + const domain = this.resolveTenantDomain(tenant); + if (!shared || !domain) { + return null; + } + + const timestamp = String(Math.floor(Date.now() / 1000)); + const payload = `${timestamp}.${method}.${SUPER_SETTINGS_CANONICAL_PATH}.${domain}`; + const signature = createHmac('sha256', shared) + .update(payload, 'utf8') + .digest('hex'); + + return { + 'X-Abshode-Domain': domain, + 'X-Abshode-Timestamp': timestamp, + 'X-Abshode-Signature': signature, + }; + } + + private resolveTenantDomain(tenant: { + domain: string | null; + domains: unknown; + nodeServer: string | null; + }): string { + // Prefer host of the URL we call — must match product SUPERADMIN_SMS_DOMAIN + const fromNode = this.normalizeHost(tenant.nodeServer); + if (fromNode) return fromNode; + + const primary = this.normalizeHost(tenant.domain); + if (primary) return primary; + + for (const value of this.readDomains(tenant.domains)) { + const host = this.normalizeHost(value); + if (host) return host; + } + return ''; + } + + 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 async requireTenant(tenantId: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId },