Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.40% covered (warning)
55.40%
77 / 139
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SLSWC
55.40% covered (warning)
55.40%
77 / 139
50.00% covered (danger)
50.00%
5 / 10
109.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 refresh_licence_details
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 deactivate_licence
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 activate_licence
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 get_remote_product_information
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_remote_check_update
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 software_details_to_plugin_update
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 get_rest_url
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 server_request
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
2
 validate_response
41.18% covered (danger)
41.18%
28 / 68
0.00% covered (danger)
0.00%
0 / 1
75.82
1<?php
2/**
3 *
4 * @see https://licenseserver.io/
5 *
6 * @package brianhenryie/bh-wp-plugin-updater
7 */
8
9namespace BrianHenryIE\WP_Plugin_Updater\Integrations\SLSWC;
10
11use BrianHenryIE\WP_Plugin_Updater\Exception\Licence_Does_Not_Exist_Exception;
12use BrianHenryIE\WP_Plugin_Updater\Exception\Licence_Key_Not_Set_Exception;
13use BrianHenryIE\WP_Plugin_Updater\Exception\Max_Activations_Exception;
14use BrianHenryIE\WP_Plugin_Updater\Exception\Plugin_Updater_Exception_Abstract;
15use BrianHenryIE\WP_Plugin_Updater\Exception\Slug_Not_Found_On_Server_Exception;
16use BrianHenryIE\WP_Plugin_Updater\Integrations\Integration_Interface;
17use BrianHenryIE\WP_Plugin_Updater\Licence;
18use BrianHenryIE\WP_Plugin_Updater\Model\Plugin_Info_Interface;
19use BrianHenryIE\WP_Plugin_Updater\Model\Plugin_Update;
20use BrianHenryIE\WP_Plugin_Updater\Model\Plugin_Update_Interface;
21use BrianHenryIE\WP_Plugin_Updater\Integrations\SLSWC\Model\Check_Updates_Response;
22use BrianHenryIE\WP_Plugin_Updater\Integrations\SLSWC\Model\License_Response;
23use BrianHenryIE\WP_Plugin_Updater\Integrations\SLSWC\Model\Product_Response;
24use BrianHenryIE\WP_Plugin_Updater\Integrations\SLSWC\Model\Software_Details;
25use BrianHenryIE\WP_Plugin_Updater\Settings_Interface;
26use BrianHenryIE\WP_Plugin_Updater\WP_Includes\CLI;
27use BrianHenryIE\WP_Plugin_Updater\WP_Includes\Cron;
28use DateTimeImmutable;
29use JsonMapper\Handler\FactoryRegistry;
30use JsonMapper\Handler\PropertyMapper;
31use JsonMapper\JsonMapperBuilder;
32use Psr\Log\LoggerAwareTrait;
33use Psr\Log\LoggerInterface;
34
35class SLSWC implements Integration_Interface {
36    use LoggerAwareTrait;
37
38    const REMOTE_REST_API_BASE = 'wp-json/slswc/v1/';
39
40    protected Licence $licence;
41
42    public function __construct(
43        protected Settings_Interface $settings,
44        LoggerInterface $logger,
45    ) {
46        $this->setLogger( $logger );
47    }
48
49
50    /**
51     * Get the licence and product details from the licence server.
52     *
53     * @used-by Cron::handle_update_check_cron_job()
54     * @used-by CLI
55     * @throws Plugin_Updater_Exception_Abstract
56     */
57    public function refresh_licence_details( Licence $licence ): Licence {
58
59        if ( is_null( $licence->get_licence_key() ) ) {
60            throw new Licence_Key_Not_Set_Exception();
61        }
62
63        // TODO: This should never be called on a pageload.
64
65        // TODO: Do not continuously retry.
66
67        $response = $this->server_request( $licence, 'check_update' ); // ?? "check update"? I think maybe this should be "activate".
68
69        $licence->set_status( $response->get_status() );
70        $licence->set_last_updated( new DateTimeImmutable() );
71
72        return $licence;
73    }
74
75    /**
76     * Send a HTTP request to deactivate the licence from this site.
77     *
78     * Is this a good idea? Should it only be possible from the licence server?
79     *
80     * https://updatestest.bhwp.ie/wp-json/slswc/v1/deactivate?slug=a-plugin
81     *
82     * @param Licence $licence The licence to deactivate.
83     *
84     * @throws Plugin_Updater_Exception_Abstract
85     */
86    public function deactivate_licence( Licence $licence ): Licence {
87
88        if ( is_null( $licence->get_licence_key() ) ) {
89            throw new Licence_Key_Not_Set_Exception();
90        }
91
92        $response = $this->server_request( $licence, 'deactivate' );
93
94        $licence->set_status( $response->get_status() );
95        $licence->set_expiry_date( $response->get_expires() );
96
97        return $licence;
98    }
99
100    /**
101     * Activate the licence on this site.
102     *
103     * https://bhwp.ie/wp-json/slswc/v1/activate?slug=a-plugin&license_key=ffa19a46c4202cf1dac17b8b556deff3f2a3cc9a
104     *
105     * @param Licence $licence The licence to activate.
106     *
107     * @throws Plugin_Updater_Exception_Abstract
108     */
109    public function activate_licence( Licence $licence ): Licence {
110
111        if ( is_null( $licence->get_licence_key() ) ) {
112            throw new Licence_Key_Not_Set_Exception();
113        }
114
115        $response = $this->server_request( $licence, 'activate', License_Response::class );
116
117        $licence->set_status( $response->get_status() );
118
119        // TODO: string -> DateTime
120        // $licence->set_expires( $response_body->expires );
121
122        return $licence;
123    }
124
125    /**
126     * Returns null when it could not fetch the product information.
127     *
128     * @param Licence $licence
129     */
130    public function get_remote_product_information( Licence $licence ): ?Plugin_Info_Interface {
131
132        // I think maybe the difference between check_update and product is one expects a valid licence
133        /** @var Product_Response $response */
134        $response = $this->server_request( $licence, 'product', Product_Response::class );
135
136        return $response->get_product();
137    }
138
139    /**
140     * Returns null when it could not fetch the product information.
141     *
142     * @param Licence $licence
143     */
144    public function get_remote_check_update( Licence $licence ): ?Plugin_Update_Interface {
145
146        // I think maybe the difference between check_update and product is one expects a valid licence.
147        /** @var Check_Updates_Response $response */
148        $response = $this->server_request( $licence, 'check_update', Check_Updates_Response::class );
149
150        // if ( update_option( $this->settings->get_check_update_option_name(), $response->get_software_details() ) ) {
151        // $this->logger->debug( 'Updated check_update option with `Software_Details` object' );
152        // }
153
154        return $this->software_details_to_plugin_update( $response->get_software_details() );
155    }
156
157    /**
158     * Convert a Software_Details object to a Plugin_Update_Interface object.
159     *
160     * @param Software_Details $software_details
161     */
162    protected function software_details_to_plugin_update( Software_Details $software_details ): Plugin_Update_Interface {
163
164        return new Plugin_Update(
165            id: $software_details->get_id(),
166            slug: $software_details->get_slug(),
167            version: $software_details->get_version(),
168            url: $software_details->get_homepage(),
169            package: $software_details->get_package(),
170            tested: $software_details->get_tested(),
171            requires_php: $software_details->get_requires(),
172            autoupdate: null,
173            icons: null, // $software_details->get_icons(),
174            banners: null,
175            banners_rtl: null, // $software_details->get_banners_rtl(),
176            translations: null, // $software_details->get_translations(),
177        );
178    }
179
180    /**
181     * Append the REST path and action to the server provided in the Settings.
182     *
183     * If the provided server URL does not have http/https, https is assumed.
184     *
185     * https://my-domain.com/wp-json/slswc/v1/product
186     */
187    protected function get_rest_url( string $action ): string {
188        $licence_server_host = $this->settings->get_licence_server_host();
189
190        $scheme = wp_parse_url( $licence_server_host, PHP_URL_SCHEME ) ?? 'https';
191        $host   = wp_parse_url( $licence_server_host, PHP_URL_HOST );
192        $path   = wp_parse_url( $licence_server_host, PHP_URL_PATH );
193
194        return trailingslashit( "{$scheme}://{$host}{$path}" ) . self::REMOTE_REST_API_BASE . $action;
195    }
196
197    /**
198     * Send a request to the server.
199     *
200     * @param Licence $licence
201     * @param string  $action activate|deactivate|check_update|product.
202     * @param string  $type The class to map the response to.
203     * @throws
204     */
205    protected function server_request( Licence $licence, string $action, string $type = License_Response::class ) {
206
207        $request_info = array(
208            'slug'        => $this->settings->get_plugin_slug(),
209            'license_key' => $licence->get_licence_key(),
210            'domain'      => get_home_url(), // Ideally, the server would use the HTTP user agent header, which contains the URL.
211        );
212
213        /**
214         * Build the server url api end point fix url build to support the WordPress API.
215         *
216         * https://my-domain.com/wp-json/slswc/v1/product/?slug=plugin-slug&licence_key=licence-key
217         */
218        $server_request_url = add_query_arg(
219            $request_info,
220            $this->get_rest_url( $action )
221        );
222
223        // Options to parse the wp_safe_remote_get() call.
224        $request_options = array( 'timeout' => 30 );
225
226        // Query the license server.
227        $endpoint_get_actions = apply_filters( 'slswc_client_get_actions', array( 'product', 'products' ) );
228        if ( in_array( $action, $endpoint_get_actions, true ) ) {
229            $response = wp_remote_get( $server_request_url, $request_options );
230        } else {
231            $response = wp_remote_post( $server_request_url, $request_options );
232        }
233
234        // Validate that the response is valid not what the response is.
235        // Check if there is an error and display it if there is one, otherwise process the response.
236        // @throws
237        $this->validate_response(
238            array(
239                'server_request_url' => $server_request_url,
240                'request_options'    => $request_options,
241            ),
242            $response
243        );
244
245        $factory_registry = new FactoryRegistry();
246        $mapper           = JsonMapperBuilder::new()
247                                            ->withDocBlockAnnotationsMiddleware()
248                                            ->withObjectConstructorMiddleware( $factory_registry )
249                                            ->withPropertyMapper( new PropertyMapper( $factory_registry ) )
250                                            ->withTypedPropertiesMiddleware()
251                                            ->withNamespaceResolverMiddleware()
252                                            ->build();
253
254        return $mapper->mapToClassFromString( wp_remote_retrieve_body( $response ), $type );
255    }
256
257    /**
258     * Validate the license server response to ensure its valid response not what the response is.
259     *
260     * @param array           $request
261     * @param \WP_Error|array $response
262     *
263     * @throws Plugin_Updater_Exception_Abstract
264     */
265    protected function validate_response( array $request, $response ): void {
266
267        $this->logger->debug(
268            'Validating response',
269            array(
270                'request'  => $request,
271                'response' => $response,
272            )
273        );
274
275        $this->logger->debug( $response['body'] );
276
277        if ( ! empty( $response ) ) {
278
279            // Can't talk to the server at all, output the error.
280            if ( is_wp_error( $response ) ) {
281                throw new \Exception(
282                    sprintf(
283                    // translators: 1. Error message.
284                        __( 'HTTP Error: %1$s. %2$s', 'bh-wp-plugin-updater' ),
285                        $response->get_error_message(),
286                        $request['server_request_url']
287                    ),
288                    (int) $response->get_error_code()
289                );
290            }
291
292            // There was a problem with the initial request.
293            if ( ! isset( $response['response']['code'] ) ) {
294                throw new \Exception(
295                // 'slswc_no_response_code',
296                    __( 'wp_safe_remote_get() returned an unexpected result.', 'bh-wp-plugin-updater' )
297                );
298            }
299
300            // Slug_Not_Found_On_Server_Exception
301
302            if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
303                $body = json_decode( $response['body'] );
304                if ( isset( $body->message ) ) {
305                    switch ( substr( $body->message, 0, 30 ) ) {
306                        case substr( 'You have reached the maximum number of allowed activations on staging domain', 0, 30 ):
307                            throw new Max_Activations_Exception();
308                        default:
309                            break;
310                    }
311                }
312            }
313
314            // There is a validation error on the server side, output the problem.
315            if ( 404 === $response['response']['code'] ) {
316                // This could be because the server does not have the License Server plugin active.
317
318                // This could be because the plugin slug is not found on the server.
319
320                $this->logger->error( $response['body'] );
321
322                throw new \Exception(
323                    __( '404 There was a problem with the license server. ' . $request['server_request_url'], 'bh-wp-plugin-updater' ),
324                );
325            }
326
327            if ( 400 === $response['response']['code'] ) {
328
329                $body = json_decode( $response['body'] );
330
331                $this->logger->error( '`json:' . json_encode( $body ) . '`' );
332
333                switch ( substr( $body->message, 0, 30 ) ) {
334                    case substr( 'Invalid parameter(s): license_key, slug', 0, 30 ):
335                        throw new Licence_Does_Not_Exist_Exception();
336                    case substr( 'Invalid parameter(s): slug', 0, 30 ):
337                        throw new Slug_Not_Found_On_Server_Exception();
338                    default:
339                        break;
340                }
341
342                foreach ( $body->data->params as $param => $message ) {
343                    throw new \Exception(
344                    // 'slswc_validation_failed',
345                        sprintf(
346                        // translators: %s: Error/response message.
347                            __( 'There was a problem with your license: %s', 'bh-wp-plugin-updater' ),
348                            $message
349                        )
350                    );
351                }
352            }
353
354            // The server is broken.
355            if ( 500 === $response['response']['code'] ) {
356                throw new \Exception(
357                // 'slswc_internal_server_error',
358                    sprintf(
359                    // translators: %s: the http response code from the server.
360                        __( 'There was a problem with the license server: HTTP response code is : %s', 'bh-wp-plugin-updater' ),
361                        $response['response']['code']
362                    )
363                );
364            }
365
366            if ( 200 !== $response['response']['code'] ) {
367                throw new \Exception(
368                // 'slswc_unexpected_response_code',
369                    sprintf(
370                        __( 'HTTP response code is : % s, expecting ( 200 )', 'bh-wp-plugin-updater' ),
371                        $response['response']['code']
372                    )
373                );
374            }
375
376            // TODO: delete. When the json fails to parse, that will throw an error.
377            if ( empty( $response['body'] ) ) {
378                throw new \Exception(
379                // 'slswc_no_response',
380                    __( 'The server returned no response.', 'bh-wp-plugin-updater' )
381                );
382            }
383        }
384    }
385}