import { BadGatewayException, 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 (!auth) { return { stub: true, message: 'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ push ممکن نیست.', tenant: { id: tenant.id, slug: tenant.slug, nodeServer: tenant.nodeServer, }, settings: [], }; } try { const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), { headers: { ...auth, Accept: 'application/json', }, timeout: 15000, }); return { stub: false, settings: data }; } catch (error) { throw new BadGatewayException( this.axiosMessage(error, 'خواندن تنظیمات از سرور نسخه ناموفق بود'), ); } } async updateSettings( tenantId: string, payload: Record, userId?: string, ) { const tenant = await this.requireTenant(tenantId); const auth = this.buildAuthHeaders(tenant, 'POST'); if (!auth) { await this.audit.log({ action: 'settings.proxy.stub_update', entityType: 'tenant_settings', entityId: tenantId, tenantId, userId, after: payload as Prisma.InputJsonValue, meta: { stub: true }, }); return { stub: true, message: 'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ تغییر فقط در audit ثبت شد (stub).', accepted: payload, }; } try { const { data } = await axios.post( this.resolveSettingsUrl(tenant.nodeServer), payload, { headers: { ...auth, Accept: 'application/json', 'Content-Type': 'application/json', }, timeout: 20000, }, ); await this.audit.log({ action: 'settings.proxy.update', entityType: 'tenant_settings', entityId: tenantId, tenantId, userId, after: payload as Prisma.InputJsonValue, meta: { authMode: auth.Authorization ? 'bearer' : 'hmac', } as Prisma.InputJsonValue, }); return { stub: false, result: data }; } catch (error) { throw new BadGatewayException( this.axiosMessage(error, 'ذخیره تنظیمات روی سرور نسخه ناموفق بود'), ); } } /** * Auth to product: * 1) Tenant remoteApiToken as Bearer (per-tenant) * 2) Else SMS shared secret as Bearer (CDN-safe; same secret as SMS) * + optional HMAC headers for non-CDN installs */ 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(); if (!shared) { return null; } // Bearer with shared secret survives CDNs that strip custom X-* headers. const headers: Record = { Authorization: `Bearer ${shared}`, }; const domain = this.resolveTenantDomain(tenant); if (domain) { 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'); headers['X-Abshode-Domain'] = domain; headers['X-Abshode-Timestamp'] = timestamp; headers['X-Abshode-Signature'] = signature; } return headers; } 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 }, }); if (!tenant) { throw new NotFoundException('نسخه یافت نشد'); } return tenant; } private joinUrl(base: string, path: string) { return `${base.replace(/\/$/, '')}${path}`; } /** * nodeServer may be `https://host` or `https://host/api`. * Laravel exposes both `/super/settings` (web) and `/api/super/settings`. */ private resolveSettingsUrl(nodeServer: string): string { const base = String(nodeServer || '').replace(/\/$/, ''); if (/\/api$/i.test(base)) { return this.joinUrl(base, '/super/settings'); } return this.joinUrl(base, '/api/super/settings'); } private axiosMessage(error: unknown, fallback: string) { if (axios.isAxiosError(error)) { const status = error.response?.status; const body = error.response?.data; return `${fallback}${status ? ` (HTTP ${status})` : ''}${ body ? `: ${JSON.stringify(body)}` : '' }`; } return fallback; } }