2026-08-18 16:10:37 +03:30
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-25 03:01:07 +03:30
|
|
|
* خواندن ۱۴ دسترسی سوپرادمین (منبع حقیقت can_edit=0 برای قابلیتها).
|
2026-08-18 16:10:37 +03:30
|
|
|
*
|
2026-08-24 21:22:44 +03:30
|
|
|
* Public اول (دامنهمحور، بدون وابستگی به HMAC): GET {origin}/api/public/program-settings?domain=
|
2026-08-18 16:10:37 +03:30
|
|
|
* Full (با HMAC/Bearer): GET {origin}/api/remote/program-settings
|
2026-08-24 21:22:44 +03:30
|
|
|
*
|
2026-08-25 03:01:07 +03:30
|
|
|
* Overlay فقط کلیدهای SUPERADMIN_OWNED_KEYS را نگه میدارد؛ پاسخ remote ممکن است
|
|
|
|
|
* کلیدهای دیگری هم برگرداند ولی آنها روی tenant اعمال نمیشوند.
|
2026-08-18 16:10:37 +03:30
|
|
|
*/
|
|
|
|
|
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
|
|
|
|
|
{
|
2026-08-24 21:22:44 +03:30
|
|
|
$public = $this->fetchPublic();
|
|
|
|
|
if ($public !== null) {
|
|
|
|
|
return $public;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:10:37 +03:30
|
|
|
if ($this->canAuth()) {
|
|
|
|
|
$items = $this->fetchRemoteFull();
|
|
|
|
|
if ($items !== null) {
|
|
|
|
|
return $items;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 21:22:44 +03:30
|
|
|
return null;
|
2026-08-18 16:10:37 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
{
|
2026-08-24 21:22:44 +03:30
|
|
|
$domains = $this->domainCandidates();
|
|
|
|
|
if ($domains === []) {
|
2026-08-18 16:10:37 +03:30
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 21:22:44 +03:30
|
|
|
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]);
|
2026-08-18 16:10:37 +03:30
|
|
|
|
2026-08-24 21:22:44 +03:30
|
|
|
if (! $response->successful() || ! $this->isJsonResponse($response)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-18 16:10:37 +03:30
|
|
|
|
2026-08-24 21:22:44 +03:30
|
|
|
$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(),
|
|
|
|
|
]);
|
2026-08-18 16:10:37 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 !== '') {
|
2026-08-24 21:22:44 +03:30
|
|
|
$domain = $this->domainCandidates()[0] ?? '';
|
2026-08-18 16:10:37 +03:30
|
|
|
$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', ''));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 21:22:44 +03:30
|
|
|
/**
|
|
|
|
|
* دامنههای کاندید برای resolve نسخه در سوپرادمین.
|
|
|
|
|
*
|
|
|
|
|
* @return list<string>
|
|
|
|
|
*/
|
|
|
|
|
private function domainCandidates(): array
|
2026-08-18 16:10:37 +03:30
|
|
|
{
|
2026-08-24 21:22:44 +03:30
|
|
|
$candidates = [];
|
|
|
|
|
|
2026-08-18 16:10:37 +03:30
|
|
|
$override = trim((string) config('services.superadmin.sms_domain', ''));
|
|
|
|
|
if ($override !== '') {
|
2026-08-24 21:22:44 +03:30
|
|
|
$candidates[] = $this->normalizeHost($override);
|
2026-08-18 16:10:37 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$fromApp = parse_url((string) config('app.url'), PHP_URL_HOST);
|
2026-08-24 21:22:44 +03:30
|
|
|
if (is_string($fromApp) && $fromApp !== '') {
|
|
|
|
|
$candidates[] = $this->normalizeHost($fromApp);
|
|
|
|
|
}
|
2026-08-18 16:10:37 +03:30
|
|
|
|
2026-08-24 21:22:44 +03:30
|
|
|
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;
|
2026-08-18 16:10:37 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|