Stored DB prices were treated as live quotes, and SuperAdmin overlay overwrote tenant keys like trading hours. Keep overlay to the 14 access flags and hide fallback prices when the panel says so. Co-authored-by: Cursor <cursoragent@cursor.com>
291 lines
8.6 KiB
PHP
291 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* خواندن ۱۴ دسترسی سوپرادمین (منبع حقیقت can_edit=0 برای قابلیتها).
|
|
*
|
|
* Public اول (دامنهمحور، بدون وابستگی به HMAC): GET {origin}/api/public/program-settings?domain=
|
|
* Full (با HMAC/Bearer): GET {origin}/api/remote/program-settings
|
|
*
|
|
* Overlay فقط کلیدهای SUPERADMIN_OWNED_KEYS را نگه میدارد؛ پاسخ remote ممکن است
|
|
* کلیدهای دیگری هم برگرداند ولی آنها روی tenant اعمال نمیشوند.
|
|
*/
|
|
class SuperAdminProgramSettingsClient
|
|
{
|
|
public const REMOTE_PATH = '/api/remote/program-settings';
|
|
|
|
public const PUBLIC_PATH = '/api/public/program-settings';
|
|
|
|
public const RETRY_ROUNDS = 3;
|
|
|
|
public function fetchItems(): ?array
|
|
{
|
|
$public = $this->fetchPublic();
|
|
if ($public !== null) {
|
|
return $public;
|
|
}
|
|
|
|
if ($this->canAuth()) {
|
|
$items = $this->fetchRemoteFull();
|
|
if ($items !== null) {
|
|
return $items;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function canAuth(): bool
|
|
{
|
|
return $this->sharedSecret() !== '' || $this->bearerToken() !== '';
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>|null
|
|
*/
|
|
private function fetchRemoteFull(): ?array
|
|
{
|
|
foreach ($this->attemptOrigins() as $origin) {
|
|
try {
|
|
$response = Http::acceptJson()
|
|
->withHeaders(array_merge($this->authHeaders('GET', self::REMOTE_PATH), [
|
|
'Cache-Control' => 'no-store',
|
|
]))
|
|
->timeout(6)
|
|
->get($origin.self::REMOTE_PATH);
|
|
|
|
if (! $response->successful() || ! $this->isJsonResponse($response)) {
|
|
Log::warning('SuperAdmin program-settings remote failed', [
|
|
'origin' => $origin,
|
|
'status' => $response->status(),
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$items = $this->extractItems($response->json());
|
|
if ($items !== null) {
|
|
return $items;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('SuperAdmin program-settings remote exception', [
|
|
'origin' => $origin,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>|null
|
|
*/
|
|
private function fetchPublic(): ?array
|
|
{
|
|
$domains = $this->domainCandidates();
|
|
if ($domains === []) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($domains as $domain) {
|
|
foreach ($this->attemptOrigins() as $origin) {
|
|
try {
|
|
$response = Http::acceptJson()
|
|
->withHeaders(['Cache-Control' => 'no-store'])
|
|
->timeout(6)
|
|
->get($origin.self::PUBLIC_PATH, ['domain' => $domain]);
|
|
|
|
if (! $response->successful() || ! $this->isJsonResponse($response)) {
|
|
continue;
|
|
}
|
|
|
|
$items = $this->extractItems($response->json());
|
|
if ($items !== null) {
|
|
return $items;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('SuperAdmin public program-settings exception', [
|
|
'origin' => $origin,
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* سه دامنه × سه دور (مستر، بعد اسلیوها).
|
|
*
|
|
* @return iterable<int, string>
|
|
*/
|
|
private function attemptOrigins(): iterable
|
|
{
|
|
$origins = $this->origins();
|
|
for ($round = 1; $round <= self::RETRY_ROUNDS; $round++) {
|
|
foreach ($origins as $origin) {
|
|
yield $origin;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function origins(): array
|
|
{
|
|
$defaults = [
|
|
'https://adm.montzar.ir',
|
|
'https://adm.montech.ir',
|
|
'https://adm.montest.ir',
|
|
];
|
|
|
|
$extra = [];
|
|
$explicit = trim((string) config('services.superadmin.origin', ''));
|
|
if ($explicit !== '') {
|
|
$extra[] = rtrim($explicit, '/');
|
|
}
|
|
|
|
$sms = trim((string) config('services.superadmin.sms_base_url', ''));
|
|
if ($sms !== '') {
|
|
$parsed = parse_url($sms);
|
|
if (! empty($parsed['scheme']) && ! empty($parsed['host'])) {
|
|
$port = isset($parsed['port']) ? ':'.$parsed['port'] : '';
|
|
$extra[] = $parsed['scheme'].'://'.$parsed['host'].$port;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique(array_merge($extra, $defaults)));
|
|
}
|
|
|
|
private function isJsonResponse($response): bool
|
|
{
|
|
$contentType = strtolower((string) $response->header('Content-Type'));
|
|
|
|
return str_contains($contentType, 'json');
|
|
}
|
|
|
|
/**
|
|
* @param mixed $data
|
|
* @return array<int, array<string, mixed>>|null
|
|
*/
|
|
private function extractItems($data): ?array
|
|
{
|
|
if (! is_array($data)) {
|
|
return null;
|
|
}
|
|
if (array_is_list($data)) {
|
|
return $data;
|
|
}
|
|
foreach (['settings', 'items', 'data'] as $key) {
|
|
if (isset($data[$key]) && is_array($data[$key])) {
|
|
return $data[$key];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function authHeaders(string $method, string $canonicalPath): array
|
|
{
|
|
$secret = $this->sharedSecret();
|
|
if ($secret !== '') {
|
|
$domain = $this->domainCandidates()[0] ?? '';
|
|
$timestamp = (string) time();
|
|
$payload = $timestamp.'.'.strtoupper($method).'.'.$canonicalPath.'.'.$domain;
|
|
$signature = hash_hmac('sha256', $payload, $secret);
|
|
|
|
return [
|
|
'X-Abshode-Domain' => $domain,
|
|
'X-Abshode-Timestamp' => $timestamp,
|
|
'X-Abshode-Signature' => $signature,
|
|
];
|
|
}
|
|
|
|
$token = $this->bearerToken();
|
|
if ($token !== '') {
|
|
return [
|
|
'Authorization' => 'Bearer '.$token,
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private function sharedSecret(): string
|
|
{
|
|
return trim((string) config('services.superadmin.sms_shared_secret', ''));
|
|
}
|
|
|
|
private function bearerToken(): string
|
|
{
|
|
return trim((string) config('services.superadmin.remote_token', ''));
|
|
}
|
|
|
|
/**
|
|
* دامنههای کاندید برای resolve نسخه در سوپرادمین.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function domainCandidates(): array
|
|
{
|
|
$candidates = [];
|
|
|
|
$override = trim((string) config('services.superadmin.sms_domain', ''));
|
|
if ($override !== '') {
|
|
$candidates[] = $this->normalizeHost($override);
|
|
}
|
|
|
|
$fromApp = parse_url((string) config('app.url'), PHP_URL_HOST);
|
|
if (is_string($fromApp) && $fromApp !== '') {
|
|
$candidates[] = $this->normalizeHost($fromApp);
|
|
}
|
|
|
|
try {
|
|
$request = request();
|
|
if ($request) {
|
|
$candidates[] = $this->normalizeHost((string) $request->getHost());
|
|
$forwarded = (string) $request->headers->get('X-Forwarded-Host', '');
|
|
if ($forwarded !== '') {
|
|
$candidates[] = $this->normalizeHost(explode(',', $forwarded)[0] ?? '');
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// CLI / بدون request
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($candidates as $host) {
|
|
if ($host === '' || in_array($host, $out, true)) {
|
|
continue;
|
|
}
|
|
if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
|
|
continue;
|
|
}
|
|
$out[] = $host;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function normalizeHost(string $value): string
|
|
{
|
|
$host = strtolower(trim($value));
|
|
$host = preg_replace('#^https?://#', '', $host) ?? $host;
|
|
$host = explode('/', $host)[0] ?? $host;
|
|
$host = explode(':', $host)[0] ?? $host;
|
|
|
|
return $host;
|
|
}
|
|
}
|