PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 All 138 releases
wpforo / admin / pages / news / src / Services / ApiService.php

ApiService.php in wpForo Forum 3.1.6, at admin/pages/news/src/Services/ApiService.php

176 lines 7.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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