2026-07-23 10:25:32 +03:30
|
|
|
import {
|
|
|
|
|
BadGatewayException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
2026-08-11 19:33:13 +03:30
|
|
|
import { ConfigService } from '@nestjs/config';
|
2026-07-23 10:25:32 +03:30
|
|
|
import axios from 'axios';
|
2026-08-11 19:33:13 +03:30
|
|
|
import { createHmac } from 'crypto';
|
2026-07-23 10:25:32 +03:30
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
import { AuditService } from '../audit/audit.service';
|
|
|
|
|
|
2026-08-11 19:33:13 +03:30
|
|
|
const SUPER_SETTINGS_CANONICAL_PATH = '/api/super/settings';
|
|
|
|
|
|
2026-07-23 10:25:32 +03:30
|
|
|
@Injectable()
|
|
|
|
|
export class SettingsProxyService {
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly audit: AuditService,
|
2026-08-11 19:33:13 +03:30
|
|
|
private readonly config: ConfigService,
|
2026-07-23 10:25:32 +03:30
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
async getSettings(tenantId: string) {
|
|
|
|
|
const tenant = await this.requireTenant(tenantId);
|
2026-08-11 19:33:13 +03:30
|
|
|
const auth = this.buildAuthHeaders(tenant, 'GET');
|
2026-07-23 10:25:32 +03:30
|
|
|
|
2026-08-11 19:33:13 +03:30
|
|
|
if (!auth) {
|
2026-07-23 10:25:32 +03:30
|
|
|
return {
|
|
|
|
|
stub: true,
|
|
|
|
|
message:
|
2026-08-11 19:33:13 +03:30
|
|
|
'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ push ممکن نیست.',
|
2026-07-23 10:25:32 +03:30
|
|
|
tenant: {
|
|
|
|
|
id: tenant.id,
|
|
|
|
|
slug: tenant.slug,
|
|
|
|
|
nodeServer: tenant.nodeServer,
|
|
|
|
|
},
|
|
|
|
|
settings: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-08-11 16:05:04 +03:30
|
|
|
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
|
|
|
|
|
headers: {
|
2026-08-11 19:33:13 +03:30
|
|
|
...auth,
|
2026-08-11 16:05:04 +03:30
|
|
|
Accept: 'application/json',
|
2026-07-23 10:25:32 +03:30
|
|
|
},
|
2026-08-11 16:05:04 +03:30
|
|
|
timeout: 15000,
|
|
|
|
|
});
|
2026-07-23 10:25:32 +03:30
|
|
|
return { stub: false, settings: data };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new BadGatewayException(
|
|
|
|
|
this.axiosMessage(error, 'خواندن تنظیمات از سرور نسخه ناموفق بود'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateSettings(
|
|
|
|
|
tenantId: string,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
userId?: string,
|
|
|
|
|
) {
|
|
|
|
|
const tenant = await this.requireTenant(tenantId);
|
2026-08-11 19:33:13 +03:30
|
|
|
const auth = this.buildAuthHeaders(tenant, 'POST');
|
2026-07-23 10:25:32 +03:30
|
|
|
|
2026-08-11 19:33:13 +03:30
|
|
|
if (!auth) {
|
2026-07-23 10:25:32 +03:30
|
|
|
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:
|
2026-08-11 19:33:13 +03:30
|
|
|
'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ تغییر فقط در audit ثبت شد (stub).',
|
2026-07-23 10:25:32 +03:30
|
|
|
accepted: payload,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { data } = await axios.post(
|
2026-08-11 16:05:04 +03:30
|
|
|
this.resolveSettingsUrl(tenant.nodeServer),
|
2026-07-23 10:25:32 +03:30
|
|
|
payload,
|
|
|
|
|
{
|
|
|
|
|
headers: {
|
2026-08-11 19:33:13 +03:30
|
|
|
...auth,
|
2026-07-23 10:25:32 +03:30
|
|
|
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,
|
2026-08-11 19:33:13 +03:30
|
|
|
meta: {
|
|
|
|
|
authMode: auth.Authorization ? 'bearer' : 'hmac',
|
|
|
|
|
} as Prisma.InputJsonValue,
|
2026-07-23 10:25:32 +03:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { stub: false, result: data };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new BadGatewayException(
|
|
|
|
|
this.axiosMessage(error, 'ذخیره تنظیمات روی سرور نسخه ناموفق بود'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 19:33:13 +03:30
|
|
|
/**
|
2026-08-11 19:53:30 +03:30
|
|
|
* 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
|
2026-08-11 19:33:13 +03:30
|
|
|
*/
|
|
|
|
|
private buildAuthHeaders(
|
|
|
|
|
tenant: {
|
|
|
|
|
remoteApiToken: string | null;
|
|
|
|
|
domain: string | null;
|
|
|
|
|
domains: unknown;
|
|
|
|
|
nodeServer: string | null;
|
|
|
|
|
},
|
|
|
|
|
method: 'GET' | 'POST',
|
|
|
|
|
): Record<string, string> | null {
|
|
|
|
|
const token = String(tenant.remoteApiToken || '').trim();
|
|
|
|
|
if (token) {
|
|
|
|
|
return { Authorization: `Bearer ${token}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shared = String(
|
|
|
|
|
this.config.get<string>('SMS_REMOTE_SHARED_SECRET') || '',
|
|
|
|
|
).trim();
|
2026-08-11 19:53:30 +03:30
|
|
|
if (!shared) {
|
2026-08-11 19:33:13 +03:30
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 19:53:30 +03:30
|
|
|
// Bearer with shared secret survives CDNs that strip custom X-* headers.
|
|
|
|
|
const headers: Record<string, string> = {
|
|
|
|
|
Authorization: `Bearer ${shared}`,
|
2026-08-11 19:33:13 +03:30
|
|
|
};
|
2026-08-11 19:53:30 +03:30
|
|
|
|
|
|
|
|
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;
|
2026-08-11 19:33:13 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 10:25:32 +03:30
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 16:05:04 +03:30
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 10:25:32 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|