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

245 lines
6.7 KiB
TypeScript

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<string, unknown>,
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<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();
if (!shared) {
return null;
}
// Bearer with shared secret survives CDNs that strip custom X-* headers.
const headers: Record<string, string> = {
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;
}
}