, string): array)|null $transport */ public function __construct( private CloudRequestSigner $signer = new CloudRequestSigner(), ?callable $transport = null ) { $this->transport = $transport ?? self::defaultTransport(); } /** * Check URLs through affilun Cloud. * * @param CloudSettings $settings Cloud settings. * @param list $urls URLs. * @param int $timestamp Timestamp. * @param string $nonce Nonce. * @param array> $contexts Optional target context keyed by URL. * * @return list * * @throws InvalidArgumentException When Cloud settings are incomplete. * @throws RuntimeException When the Cloud response fails. */ public function check( CloudSettings $settings, array $urls, int $timestamp, string $nonce, array $contexts = array() ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $cleanUrls = self::validateUrls( $urls ); if ( $cleanUrls === array() ) { return array(); } $payload = array( 'urls' => $cleanUrls ); $targets = self::targetPayloads( $cleanUrls, $contexts ); if ( $targets !== array() ) { $payload['targets'] = $targets; } $path = '/link-health/check'; $body = self::jsonEncode( $payload ); $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), 'POST', $path, $body, $timestamp, $nonce ); $headers['Content-Type'] = 'application/json'; $response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body ); $status = isset( $response['status'] ) ? (int) $response['status'] : 0; $payload = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; $data = is_array( $payload['data'] ?? null ) ? $payload['data'] : $payload; $results = is_array( $data ) ? ( $data['results'] ?? null ) : null; if ( $status < 200 || $status >= 300 || ! is_array( $results ) ) { $error = self::errorFromPayload( $payload ); // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Stored as exception metadata for controlled admin messages; not emitted here. throw new CloudApiException( 'affilun Cloud link health check', $status, $error['code'], $error['message'] ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $mapped = array(); foreach ( $results as $row ) { if ( is_array( $row ) ) { $mapped[] = CloudLinkHealthResult::fromArray( $row ); } } return $mapped; } /** * Create an asynchronous link-health job. * * @param CloudSettings $settings Cloud settings. * @param list $urls URLs. * @param int $timestamp Timestamp. * @param string $nonce Nonce. * @param array> $contexts Optional target context keyed by URL. * * @return array */ public function createJob( CloudSettings $settings, array $urls, int $timestamp, string $nonce, array $contexts = array() ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $cleanUrls = self::validateJobUrls( $urls ); if ( $cleanUrls === array() ) { return array(); } $payload = array( 'urls' => $cleanUrls ); $targets = self::targetPayloads( $cleanUrls, $contexts ); if ( $targets !== array() ) { $payload['targets'] = $targets; } $data = $this->requestJson( $settings, 'POST', '/link-health/jobs', self::jsonEncode( $payload ), $timestamp, $nonce, 'affilun Cloud link health job' ); return is_array( $data['job'] ?? null ) ? $data['job'] : array(); } /** * Fetch an asynchronous link-health job. * * @param CloudSettings $settings Cloud credentials. * @param string $jobId Cloud job id. * @param int $timestamp Signature timestamp. * @param string $nonce Signature nonce. * * @return array */ public function fetchJob( CloudSettings $settings, string $jobId, int $timestamp, string $nonce ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $jobId = self::cleanJobId( $jobId ); if ( $jobId === '' ) { return array(); } $data = $this->requestJson( $settings, 'GET', '/link-health/jobs/' . rawurlencode( $jobId ), '', $timestamp, $nonce, 'affilun Cloud link health job status' ); return is_array( $data['job'] ?? null ) ? $data['job'] : array(); } /** * Fetch asynchronous link-health job results. * * @param CloudSettings $settings Cloud credentials. * @param string $jobId Cloud job id. * @param int $timestamp Signature timestamp. * @param string $nonce Signature nonce. * * @return array{job:array,summary:array,results:list} */ public function fetchJobResults( CloudSettings $settings, string $jobId, int $timestamp, string $nonce ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $jobId = self::cleanJobId( $jobId ); if ( $jobId === '' ) { return array( 'job' => array(), 'summary' => array(), 'results' => array(), ); } $data = $this->requestJson( $settings, 'GET', '/link-health/jobs/' . rawurlencode( $jobId ) . '/results', '', $timestamp, $nonce, 'affilun Cloud link health job results' ); $results = array(); if ( is_array( $data['results'] ?? null ) ) { foreach ( $data['results'] as $row ) { if ( is_array( $row ) ) { $results[] = CloudLinkHealthResult::fromArray( $row ); } } } return array( 'job' => is_array( $data['job'] ?? null ) ? $data['job'] : array(), 'summary' => is_array( $data['summary'] ?? null ) ? $data['summary'] : array(), 'results' => $results, ); } /** * Build optional Cloud-side recurring target payloads. * * @param list $urls Submitted URLs. * @param array> $contexts Context keyed by URL. * * @return list> */ private static function targetPayloads( array $urls, array $contexts ): array { $targets = array(); foreach ( $urls as $url ) { $context = $contexts[ $url ] ?? null; if ( ! is_array( $context ) ) { continue; } $target = array( 'url' => $url ); foreach ( array( 'label', 'product_id', 'provider', 'provider_item_id' ) as $key ) { if ( is_scalar( $context[ $key ] ?? null ) ) { $value = trim( (string) $context[ $key ] ); if ( $value !== '' ) { $target[ $key ] = $value; } } } if ( is_array( $context['metadata'] ?? null ) ) { $target['metadata'] = $context['metadata']; } $targets[] = $target; } return $targets; } /** * Validate URLs for Cloud submission. * * @param list $urls Raw URLs. * * @return list */ public static function validateUrls( array $urls ): array { return self::validateUrlsWithLimit( $urls, 50 ); } /** * Validate URLs for asynchronous Cloud job submission. * * @param list $urls Raw URLs. * * @return list */ public static function validateJobUrls( array $urls ): array { return self::validateUrlsWithLimit( $urls, 5000 ); } /** * Validate URLs for Cloud submission. * * @param list $urls Raw URLs. * @param int $limit Maximum accepted URLs. * * @return list */ private static function validateUrlsWithLimit( array $urls, int $limit ): array { $valid = array(); foreach ( $urls as $url ) { $url = trim( $url ); if ( $url === '' || preg_match( '#\Ahttps?://#i', $url ) !== 1 ) { continue; } if ( function_exists( 'wp_parse_url' ) ) { $host = wp_parse_url( $url, PHP_URL_HOST ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure-unit fallback when WordPress is unavailable. $host = parse_url( $url, PHP_URL_HOST ); } if ( ! is_string( $host ) || self::isBlockedHost( $host ) ) { continue; } if ( ! in_array( $url, $valid, true ) ) { $valid[] = $url; } } return array_slice( $valid, 0, $limit ); } /** * Clean a Cloud job identifier for path use. * * @param string $jobId Job id. */ private static function cleanJobId( string $jobId ): string { $jobId = trim( $jobId ); return preg_match( '/\A[a-f0-9-]{1,64}\z/i', $jobId ) === 1 ? $jobId : ''; } /** * Execute a signed Cloud JSON request. * * @param CloudSettings $settings Cloud credentials. * @param string $method HTTP method. * @param string $path API path. * @param string $body JSON request body. * @param int $timestamp Signature timestamp. * @param string $nonce Signature nonce. * @param string $operation Operation name for diagnostics. * * @return array */ private function requestJson( CloudSettings $settings, string $method, string $path, string $body, int $timestamp, string $nonce, string $operation ): array { $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), $method, $path, $body, $timestamp, $nonce ); if ( $body !== '' ) { $headers['Content-Type'] = 'application/json'; } $response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body ); $status = isset( $response['status'] ) ? (int) $response['status'] : 0; $payload = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; $data = is_array( $payload['data'] ?? null ) ? $payload['data'] : $payload; if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { $error = self::errorFromPayload( $payload ); // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Stored as exception metadata for controlled admin messages; not emitted here. throw new CloudApiException( $operation, $status, $error['code'], $error['message'] ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $data; } /** * Whether a host is unsafe for Cloud-side checks. * * @param string $host Host. */ private static function isBlockedHost( string $host ): bool { $lower = strtolower( trim( $host, "[] \t\n\r\0\x0B" ) ); if ( $lower === 'localhost' || str_ends_with( $lower, '.local' ) ) { return true; } if ( filter_var( $lower, FILTER_VALIDATE_IP ) === false ) { return false; } return filter_var( $lower, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false; } /** * JSON encode a request payload. * * @param array $payload Payload. * * @throws RuntimeException When encoding fails. */ private static function jsonEncode( array $payload ): string { if ( function_exists( 'wp_json_encode' ) ) { $body = wp_json_encode( $payload ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit fallback when WordPress is unavailable. $body = json_encode( $payload ); } if ( ! is_string( $body ) ) { throw new RuntimeException( 'Failed to encode affilun Cloud request.' ); } return $body; } /** * Extract sanitized Cloud error metadata from a decoded response. * * @param array|null $payload Decoded response payload. * * @return array{code:string,message:string} */ private static function errorFromPayload( ?array $payload ): array { $error = is_array( $payload['error'] ?? null ) ? $payload['error'] : array(); return array( 'code' => is_scalar( $error['code'] ?? null ) ? (string) $error['code'] : '', 'message' => is_scalar( $error['message'] ?? null ) ? (string) $error['message'] : '', ); } /** * Default WordPress HTTP transport. * * @return callable * * @phpstan-return callable(string, array, string): array */ private static function defaultTransport(): callable { return static function ( string $url, array $headers, string $body ): array { $args = array( 'timeout' => 5, 'headers' => $headers, ); if ( $body === '' ) { $response = CloudHttpOptions::safeGet( $url, $args ); } else { $args['body'] = $body; $response = CloudHttpOptions::safePost( $url, $args ); } if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { return array( 'status' => 503, 'data' => null, ); } $raw = is_array( $response ) ? (string) wp_remote_retrieve_body( $response ) : ''; $decoded = json_decode( $raw, true ); return array( 'status' => is_array( $response ) ? (int) wp_remote_retrieve_response_code( $response ) : 0, 'data' => is_array( $decoded ) ? $decoded : null, ); }; } }