Files
2026-11-06 13:37:12 +08:00

82 lines
4.8 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace app\adminapi\service\iam\v2;
use RuntimeException;
use think\facade\Config;
/** Dedicated v2 application credential; never falls back to the v1 provisioning token. */
final class IamV2Client
{
public function __construct(private ?array $settings = null, private $transport = null)
{
$this->settings ??= (array) Config::get('iam', []);
if (!self::enabled($this->settings)) { throw new RuntimeException('FEATURE_DISABLED'); }
self::https((string) ($this->settings['api_url'] ?? ''));
foreach (['public_url', 'erp_url'] as $field) { self::origin((string) ($this->settings[$field] ?? '')); }
if (strlen((string) ($this->settings['v2_token'] ?? '')) < 32) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
}
public static function enabled(?array $settings = null): bool
{
$settings ??= (array) Config::get('iam', []);
return filter_var($settings['v2_enabled'] ?? false, FILTER_VALIDATE_BOOL);
}
public static function feature(string $feature, ?array $settings = null): bool
{
$settings ??= (array) Config::get('iam', []);
if (!in_array($feature, ['management', 'launch'], true)) { return false; }
return self::enabled($settings) && filter_var($settings['v2_' . $feature . '_enabled'] ?? false, FILTER_VALIDATE_BOOL);
}
public static function https(string $url): string
{
$p = parse_url($url);
if (!$p || ($p['scheme'] ?? '') !== 'https' || empty($p['host']) || isset($p['user']) || isset($p['pass'])
|| isset($p['fragment']) || isset($p['query']) || preg_match('/[\x00-\x20\x7f\\\\]/', $url)) { throw new RuntimeException('INVALID_FIXED_URL'); }
return rtrim($url, '/');
}
public static function origin(string $url): string
{
$url = self::https($url);
if ((parse_url($url, PHP_URL_PATH) ?? '') !== '') { throw new RuntimeException('INVALID_FIXED_URL'); }
return $url;
}
public function call(string $method, string $path, array $body = [], ?string $key = null): array
{
if (!preg_match('#^/[a-zA-Z0-9/_?=&%.:-]+$#D', $path) || str_contains($path, '..')) { throw new RuntimeException('INVALID_PATH'); }
$url = rtrim($this->settings['api_url'], '/') . '/internal/v2/applications/' . rawurlencode($this->settings['application_id'] ?? 'zyt') . $path;
$management = preg_match('#^/(identities|operations|audit|batch|jobs)(/|[?]|$)#D', $path) === 1;
$credential = (string) ($this->settings[$management ? 'v2_management_token' : 'v2_token'] ?? '');
if (strlen($credential) < 32 || preg_match('/[\r\n]/', $credential)
|| ($management && hash_equals((string) $this->settings['v2_token'], $credential))) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
$headers = ['Accept: application/json', 'Content-Type: application/json', 'Authorization: Bearer ' . $credential];
if ($method !== 'GET') {
$headers[] = 'X-Request-ID: ' . bin2hex(random_bytes(16));
$headers[] = 'Idempotency-Key: ' . ($key ?? bin2hex(random_bytes(24)));
}
$data = $method === 'GET' ? '' : json_encode($body, JSON_THROW_ON_ERROR);
if ($this->transport) { $r = ($this->transport)($method, $url, $headers, $data); }
else {
$c = curl_init($url); $response = '';
curl_setopt_array($c, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $data, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 10,
CURLOPT_WRITEFUNCTION => static function ($c, $chunk) use (&$response) {
if (strlen($response) + strlen($chunk) > 2097152) { return 0; } $response .= $chunk; return strlen($chunk);
}]);
$ok = curl_exec($c); $r = ['status' => curl_getinfo($c, CURLINFO_RESPONSE_CODE), 'body' => $ok === false ? '' : $response]; curl_close($c);
}
$result = json_decode($r['body'] ?? '', true);
if (!is_array($result)) { throw new RuntimeException('SERVICE_UNAVAILABLE'); }
$operationEnvelope = isset($result['id']) && is_string($result['id'])
&& in_array($result['status'] ?? '', ['pending', 'succeeded', 'failed', 'conflict'], true)
&& preg_match('#^/(operations|jobs|batch)(/|$)#D', $path);
if (($r['status'] ?? 0) < 200 || $r['status'] >= 300 || (isset($result['error']) && !$operationEnvelope)) {
$code = $result['error']['code'] ?? 'SERVICE_UNAVAILABLE';
throw new RuntimeException(preg_match('/^[A-Z_]{3,64}$/D', $code) ? $code : 'SERVICE_UNAVAILABLE');
}
return $result;
}
}