Files
api/src/settings-proxy/settings-proxy.service.ts
T

238 lines
6.5 KiB
TypeScript
Raw Normal View History

2026-07-23 10:25:32 +03:30
import {
BadGatewayException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
2026-07-23 10:25:32 +03:30
import axios from 'axios';
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';
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,
private readonly config: ConfigService,
2026-07-23 10:25:32 +03:30
) {}
async getSettings(tenantId: string) {
const tenant = await this.requireTenant(tenantId);
const auth = this.buildAuthHeaders(tenant, 'GET');
2026-07-23 10:25:32 +03:30
if (!auth) {
2026-07-23 10:25:32 +03:30
return {
stub: true,
message:
'نه 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: {
...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);
const auth = this.buildAuthHeaders(tenant, 'POST');
2026-07-23 10:25:32 +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:
'نه 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: {
...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,
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, 'ذخیره تنظیمات روی سرور نسخه ناموفق بود'),
);
}
}
/**
* 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<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();
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];
}
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;
}
}