'application/json', 'Content-Type' => 'application/json']; if (! $public) { $connection = Store::get('connection'); $token ??= Store::decrypt($connection['secret'] ?? ''); if (! preg_match('/^flx_[a-f0-9-]{36}_[a-f0-9]{64}$/Di', $token)) { throw new \RuntimeException('invalid_token'); } $headers['Authorization'] = 'Bearer '.$token; } $body = $method === 'POST' ? wp_json_encode($data ?: new \stdClass, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null; if (is_string($body) && strlen($body) > 65536) { throw new \RuntimeException('payload_too_large'); } $locale = in_array($locale, ['fa', 'en'], true) ? $locale : (str_starts_with(determine_locale(), 'fa') ? 'fa' : 'en'); $url = self::BASE.'/api/v1/wordpress'.$path.(str_contains($path, '?') ? '&' : '?').'locale='.$locale; $response = wp_safe_remote_request($url, ['method' => $method, 'timeout' => max(1, min(15, $timeout)), 'redirection' => 0, 'sslverify' => true, 'headers' => $headers, 'body' => $body, 'limit_response_size' => 8 * 1024 * 1024 + 1, 'user-agent' => 'FluxCDNSecurity/'.FLX_SECURITY_VERSION]); unset($token, $headers, $body); if (is_wp_error($response)) { throw new \RuntimeException('network_unavailable'); } $status = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if (strlen($body) > 8 * 1024 * 1024) { throw new \RuntimeException('response_too_large'); } $json = json_decode($body, true, 64); if ($status === 429) { $retry = wp_remote_retrieve_header($response, 'retry-after'); Store::set('retry_after', time() + max(1, min(DAY_IN_SECONDS, ctype_digit((string) $retry) ? (int) $retry : 300))); } if ($status < 200 || $status >= 300) { $code = $json['error']['code'] ?? ''; if ($status === 404) $code = str_contains($path, '/syncs/') ? 'sync_expired' : 'request_rejected'; if ($status >= 400 && $status < 500 && ! in_array($code, ['invalid_token', 'insufficient_scope', 'quota_exceeded', 'site_disconnected', 'sync_expired', 'catalog_changed', 'site_limit', 'token_limit', 'invalid_authorization', 'invalid_request', 'inventory_incomplete', 'request_rejected'], true)) $code = 'request_rejected'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Only a fixed allowlisted error key; translated and escaped at the rendering boundary. throw new \RuntimeException(in_array($code, ['invalid_token', 'insufficient_scope', 'quota_exceeded', 'site_disconnected', 'sync_expired', 'catalog_changed', 'site_limit', 'token_limit', 'invalid_authorization', 'invalid_request', 'inventory_incomplete', 'request_rejected'], true) ? $code : 'service_unavailable'); } if (! is_array($json) || ! isset($json['data']) || ! is_array($json['data'])) { throw new \RuntimeException('invalid_response'); } return $json; } public static function connect(#[\SensitiveParameter] string $token, float $timeout = 15): void { $deadline = microtime(true) + max(1, min(15, $timeout)); global $wp_version; $secret = Store::encrypt(trim($token)); $url = rtrim(is_multisite() ? network_site_url() : site_url(), '/'); if (wp_parse_url($url, PHP_URL_SCHEME) !== 'https') { throw new \RuntimeException('https_required'); } $installation = Store::get('installation'); if (! is_array($installation) || ($installation['url'] ?? '') !== $url) { $installation = ['id' => wp_generate_uuid4(), 'url' => $url]; Store::set('installation', $installation); } $result = self::request('POST', '/sites', ['installation_id' => $installation['id'], 'url' => $url, 'wordpress_version' => $wp_version, 'plugin_version' => FLX_SECURITY_VERSION], trim($token), timeout: max(1, $deadline - microtime(true)))['data']; if (! self::uuid($result['site_id'] ?? null) || ! is_string($result['account'] ?? null) || ! is_string($result['expires_at'] ?? null)) { throw new \RuntimeException('invalid_response'); } $previous = Store::get('connection'); if (self::uuid($previous['site_id'] ?? null) && $previous['site_id'] !== $result['site_id'] && ($previous['url'] ?? '') === $url) { try { self::request('POST', '/sites/'.$previous['site_id'].'/disconnect', token: Store::decrypt($previous['secret'] ?? ''), timeout: max(1, $deadline - microtime(true))); } catch (\Throwable $error) { if (! in_array($error->getMessage(), ['invalid_token', 'insufficient_scope', 'site_disconnected', 'connection_required'], true)) { throw new \RuntimeException('connection_switch_failed'); } } } Store::set('connection', ['site_id' => $result['site_id'], 'account' => sanitize_email($result['account']), 'expires_at' => $result['expires_at'], 'url' => $url, 'secret' => $secret]); self::rememberReceipt($result); $pendingDisconnects = Store::get('disconnects'); if (isset($pendingDisconnects[$result['site_id']])) { unset($pendingDisconnects[$result['site_id']]); if ($pendingDisconnects) Store::set('disconnects', $pendingDisconnects); else Store::remove('disconnects'); } if (($previous['site_id'] ?? null) !== $result['site_id'] || ($previous['url'] ?? null) !== $url || ($previous['account'] ?? null) !== sanitize_email($result['account'])) { Store::remove('snapshot'); Store::remove('test'); Store::prune(); } Store::remove('sync'); Store::remove('error'); Store::remove('authorization'); Store::remove('retry_after'); Store::set('inventory_dirty', true); } public static function rememberReceipt(array $response): void { if (is_string($response['disconnect_receipt'] ?? null) && preg_match('/^[a-f0-9]{64}$/D', $response['disconnect_receipt'])) { $connection = Store::get('connection'); if ($connection) { $connection['disconnect_receipt'] = Store::encrypt($response['disconnect_receipt']); Store::set('connection', $connection); } } } public static function connectionError(): string { $connection = Store::get('connection'); if (! $connection) return ''; if (strtotime($connection['expires_at'] ?? '') <= time()) return 'token_expired'; if (($connection['url'] ?? '') !== rtrim(is_multisite() ? network_site_url() : site_url(), '/')) return 'connection_required'; try { Store::decrypt($connection['secret'] ?? ''); } catch (\Throwable) { return 'connection_required'; } $error = Store::get('error', ''); return in_array($error, ['invalid_token', 'insufficient_scope', 'site_disconnected', 'connection_required'], true) ? $error : ''; } public static function connected(): bool { $connection = Store::get('connection'); return ! ConnectionChanges::pending() && ! empty($connection['secret']) && empty($connection['paused']) && strtotime($connection['expires_at'] ?? '') > time() && self::connectionError() === '' && ($connection['url'] ?? '') === rtrim(is_multisite() ? network_site_url() : site_url(), '/'); } public static function path(): string { $connection = Store::get('connection'); $url = rtrim(is_multisite() ? network_site_url() : site_url(), '/'); if (! self::uuid($connection['site_id'] ?? null) || ($connection['url'] ?? '') !== $url) { throw new \RuntimeException('connection_required'); } return '/sites/'.$connection['site_id']; } public static function uuid(mixed $value): bool { return is_string($value) && (bool) preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $value); } }