| 1 |
<?php |
| 2 |
|
| 3 |
namespace gVectors\News\Services; |
| 4 |
|
| 5 |
use gVectors\News\Config; |
| 6 |
use gVectors\News\NewsModule; |
| 7 |
|
| 8 |
// Exit if accessed directly |
| 9 |
if( ! defined( 'ABSPATH' ) ) exit; |
| 10 |
|
| 11 |
/** |
| 12 |
* Thin HTTP client for the gVectors proxy server (news + at-risk endpoints). |
| 13 |
* |
| 14 |
* IMPORTANT: every method must stay behind the master opt-in — callers pass |
| 15 |
* through ConsentService, and this class re-checks the option as a hard guard |
| 16 |
* so a coding mistake can never produce an outbound request without consent. |
| 17 |
*/ |
| 18 |
class ApiService { |
| 19 |
private $config; |
| 20 |
private $proxy_url; |
| 21 |
|
| 22 |
public function __construct( Config $config ) { |
| 23 |
$this->config = $config; |
| 24 |
$this->proxy_url = trailingslashit( $config->get_proxy_server_url() ); |
| 25 |
} |
| 26 |
|
| 27 |
/** |
| 28 |
* GET /news — global news items + the news digest email wrapper template. |
| 29 |
* Returns ['news' => [...], 'email_wrapper' => [...]] or null on failure. |
| 30 |
*/ |
| 31 |
public function get_news( ?int $since = null ): ?array { |
| 32 |
$query = []; |
| 33 |
if( $since !== null && $since > 0 ) { |
| 34 |
$query['since'] = $since; |
| 35 |
} |
| 36 |
|
| 37 |
$response = $this->request( 'news', $query ); |
| 38 |
if( empty( $response['success'] ) || ! isset( $response['data']['news'] ) || ! is_array( $response['data']['news'] ) ) { |
| 39 |
return null; |
| 40 |
} |
| 41 |
|
| 42 |
$purchase = $response['data']['purchase_email'] ?? null; |
| 43 |
$cross_sell = $response['data']['cross_sell'] ?? null; |
| 44 |
|
| 45 |
$recommendations = []; |
| 46 |
foreach( (array) ( $response['data']['recommendations'] ?? [] ) as $rec ) { |
| 47 |
if( ! is_array( $rec ) || empty( $rec['plugin_slug'] ) || ! is_string( $rec['plugin_slug'] ) ) continue; |
| 48 |
if( ! preg_match( '/^[a-zA-Z0-9_\-]+$/', $rec['plugin_slug'] ) ) continue; |
| 49 |
$recommendations[] = [ |
| 50 |
'plugin_slug' => $rec['plugin_slug'], |
| 51 |
'product_name' => isset( $rec['product_name'] ) && is_string( $rec['product_name'] ) ? $rec['product_name'] : $rec['plugin_slug'], |
| 52 |
'reason' => isset( $rec['reason'] ) && is_string( $rec['reason'] ) ? $rec['reason'] : '', |
| 53 |
]; |
| 54 |
} |
| 55 |
|
| 56 |
return [ |
| 57 |
'news' => array_values( array_filter( $response['data']['news'], [ $this, 'is_valid_news_item' ] ) ), |
| 58 |
'email_wrapper' => $this->sanitize_wrapper( $response['data']['email_wrapper'] ?? null ), |
| 59 |
'purchase_email' => is_array( $purchase ) && ! empty( $purchase['body_html'] ) && is_string( $purchase['body_html'] ) ? [ |
| 60 |
'subject' => isset( $purchase['subject'] ) && is_string( $purchase['subject'] ) ? $purchase['subject'] : '', |
| 61 |
'body_html' => $purchase['body_html'], |
| 62 |
'license_row' => isset( $purchase['license_row'] ) && is_string( $purchase['license_row'] ) ? $purchase['license_row'] : '', |
| 63 |
] : null, |
| 64 |
'recommendations' => $recommendations, |
| 65 |
'cross_sell' => is_array( $cross_sell ) && ! empty( $cross_sell['section'] ) && is_string( $cross_sell['section'] ) ? [ |
| 66 |
'subject' => isset( $cross_sell['subject'] ) && is_string( $cross_sell['subject'] ) ? $cross_sell['subject'] : '', |
| 67 |
'section' => $cross_sell['section'], |
| 68 |
'item' => isset( $cross_sell['item'] ) && is_string( $cross_sell['item'] ) ? $cross_sell['item'] : '', |
| 69 |
] : null, |
| 70 |
]; |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* GET /at-risk-licenses — subscriptions expiring soon with no active billing. |
| 75 |
* The proxy computes phases and pre-builds the reminder email; this client does no date math. |
| 76 |
* |
| 77 |
* @param array $versions plugin_slug => installed version |
| 78 |
*/ |
| 79 |
public function get_at_risk_licenses( array $versions ): ?array { |
| 80 |
$query = []; |
| 81 |
foreach( $versions as $slug => $version ) { |
| 82 |
$slug = preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $slug ); |
| 83 |
if( $slug === '' ) continue; |
| 84 |
$query[ 'versions[' . $slug . ']' ] = substr( (string) $version, 0, 20 ); |
| 85 |
} |
| 86 |
|
| 87 |
$response = $this->request( 'at-risk-licenses', $query ); |
| 88 |
if( empty( $response['success'] ) || ! isset( $response['data']['licenses'] ) || ! is_array( $response['data']['licenses'] ) ) { |
| 89 |
return null; |
| 90 |
} |
| 91 |
|
| 92 |
return [ |
| 93 |
'warning_phases' => array_map( 'intval', (array) ( $response['data']['warning_phases'] ?? [] ) ), |
| 94 |
'winback_phases' => array_map( 'intval', (array) ( $response['data']['winback_phases'] ?? [] ) ), |
| 95 |
'licenses' => $response['data']['licenses'], |
| 96 |
'past_due' => is_array( $response['data']['past_due'] ?? null ) ? $response['data']['past_due'] : [], |
| 97 |
'active_offers' => is_array( $response['data']['active_offers'] ?? null ) ? $response['data']['active_offers'] : [], |
| 98 |
'email' => $this->sanitize_wrapper( $response['data']['email'] ?? null ), |
| 99 |
'winback_email' => $this->sanitize_wrapper( $response['data']['winback_email'] ?? null ), |
| 100 |
'dunning_email' => $this->sanitize_wrapper( $response['data']['dunning_email'] ?? null ), |
| 101 |
'offer_email' => $this->sanitize_wrapper( $response['data']['offer_email'] ?? null ), |
| 102 |
]; |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Authenticated GET request to the proxy server. |
| 107 |
* Hard opt-in guard: no consent → no request, ever. |
| 108 |
*/ |
| 109 |
private function request( string $endpoint, array $query = [] ): array { |
| 110 |
if( ! get_option( $this->config->get_service_enabled_option() ) ) { |
| 111 |
return [ 'success' => false, 'error' => 'Addons service is disabled', 'code' => 'consent_missing' ]; |
| 112 |
} |
| 113 |
|
| 114 |
$url = add_query_arg( $query, $this->proxy_url . ltrim( $endpoint, '/' ) ); |
| 115 |
|
| 116 |
$response = wp_remote_get( $url, [ |
| 117 |
'timeout' => $this->config->get_request_timeout(), |
| 118 |
'sslverify' => true, |
| 119 |
'headers' => [ |
| 120 |
'Accept' => 'application/json', |
| 121 |
'X-gVectors-Site' => NewsModule::get_site_domain(), |
| 122 |
'X-gVectors-Token' => NewsModule::get_site_token(), |
| 123 |
], |
| 124 |
] ); |
| 125 |
|
| 126 |
if( is_wp_error( $response ) ) { |
| 127 |
$this->log( $endpoint, $response->get_error_message() ); |
| 128 |
|
| 129 |
return [ 'success' => false, 'error' => $response->get_error_message(), 'code' => 'wp_error' ]; |
| 130 |
} |
| 131 |
|
| 132 |
$code = wp_remote_retrieve_response_code( $response ); |
| 133 |
$data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 134 |
|
| 135 |
if( $code < 200 || $code >= 300 || ! is_array( $data ) ) { |
| 136 |
$this->log( $endpoint, 'HTTP ' . $code ); |
| 137 |
|
| 138 |
return [ 'success' => false, 'error' => $data['error'] ?? 'HTTP Error ' . $code, 'code' => $code ]; |
| 139 |
} |
| 140 |
|
| 141 |
return $data; |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Schema sanity check for a news item coming from the proxy. |
| 146 |
*/ |
| 147 |
private function is_valid_news_item( $item ): bool { |
| 148 |
return is_array( $item ) |
| 149 |
&& ! empty( $item['id'] ) && is_string( $item['id'] ) |
| 150 |
&& preg_match( '/^[a-f0-9\-]{36}$/', $item['id'] ) |
| 151 |
&& isset( $item['title'] ) && is_string( $item['title'] ) |
| 152 |
&& isset( $item['type'] ) && is_string( $item['type'] ); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Schema sanity check for email wrapper/body structures. |
| 157 |
*/ |
| 158 |
private function sanitize_wrapper( $wrapper ): ?array { |
| 159 |
if( ! is_array( $wrapper ) || empty( $wrapper['body_html'] ) || ! is_string( $wrapper['body_html'] ) ) { |
| 160 |
return null; |
| 161 |
} |
| 162 |
|
| 163 |
return [ |
| 164 |
'subject' => isset( $wrapper['subject'] ) && is_string( $wrapper['subject'] ) ? $wrapper['subject'] : '', |
| 165 |
'body_html' => $wrapper['body_html'], |
| 166 |
'item_card' => isset( $wrapper['item_card'] ) && is_string( $wrapper['item_card'] ) ? $wrapper['item_card'] : '', |
| 167 |
]; |
| 168 |
} |
| 169 |
|
| 170 |
private function log( string $endpoint, string $message ): void { |
| 171 |
if( defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 172 |
error_log( sprintf( '[%snews] %s request failed: %s', $this->config->get_prefix(), $endpoint, $message ) ); |
| 173 |
} |
| 174 |
} |
| 175 |
} |
| 176 |
|