• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

equalizedigital / accessibility-checker / 24683125413

20 Apr 2026 06:22PM UTC coverage: 57.497% (-3.0%) from 60.51%
24683125413

push

github

web-flow
Merge pull request #1315 from equalizedigital/william/pro-286-update-the-free-plugin-to-be-able-to-handle-licence

Add support for registering sites with myDot and exchanging a jwt token that can be used to share site stats

440 of 1210 new or added lines in 9 files covered. (36.36%)

2 existing lines in 2 files now uncovered.

5426 of 9437 relevant lines covered (57.5%)

4.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

41.91
/includes/classes/MyDot/Connector.php
1
<?php
2
/**
3
 * MyDot Connector Class
4
 *
5
 * Provides connection and product information for MyDot license management integration.
6
 *
7
 * @package Accessibility_Checker
8
 * @since 1.xx.x
9
 */
10

11
namespace EqualizeDigital\AccessibilityChecker\MyDot;
12

13
use EqualizeDigital\AccessibilityChecker\Admin\AdminPage\ConnectedServicesPage;
14

15
/**
16
 * Class Connector
17
 *
18
 * Handles MyDot product and license integration constants and utilities.
19
 *
20
 * @since 1.xx.x
21
 */
22
class Connector {
23

24
        /**
25
         * The product name used in MyDot licensing system.
26
         *
27
         * @since 1.xx.x
28
         *
29
         * @var string
30
         */
31
        const PRODUCT_NAME = 'Accessibility Checker Free';
32

33
        /**
34
         * The default MyDot API endpoint for license validation.
35
         *
36
         * @since 1.xx.x
37
         *
38
         * @var string
39
         */
40
        const API_ENDPOINT = 'https://my.equalizedigital.com';
41

42
        /**
43
         * The product ID used in MyDot licensing system.
44
         *
45
         * @since 1.xx.x
46
         *
47
         * @var int
48
         */
49
        const PRODUCT_ID = 1666;
50

51
        /**
52
         * TTL for transient-based admin notices (seconds).
53
         */
54
        private const NOTICE_TRANSIENT_TTL = 60;
55

56
        /**
57
         * License metadata option: stores inferred license state from EDD responses.
58
         *
59
         * This is not raw response data; it's processed/inferred state that combines:
60
         * - License type inference (free vs pro) based on product_id, item_name, or source context
61
         * - License level inference (single-site, multi-site, unlimited, lifetime) from license_limit
62
         * - Formatted/sanitized response fields (expires, site_count, activations_left, etc.)
63
         *
64
         * Single option (not scattered across multiple wp_options for better atomicity).
65
         *
66
         * @var string
67
         */
68
        private const LICENSE_METADATA_OPTION = 'edac_license_metadata';
69

70
        /**
71
         * License status constants.
72
         */
73
        private const LICENSE_STATUS_VALID   = 'valid';
74
        private const LICENSE_STATUS_EXPIRED = 'expired';
75
        private const LICENSE_STATUS_UNKNOWN = 'unknown';
76

77
        /**
78
         * License type constants.
79
         */
80
        private const LICENSE_TYPE_FREE    = 'free';
81
        private const LICENSE_TYPE_PRO     = 'pro';
82
        private const LICENSE_TYPE_UNKNOWN = 'unknown';
83

84
        /**
85
         * License level constants.
86
         */
87
        private const LICENSE_LEVEL_SINGLE_SITE = 'single-site';
88
        private const LICENSE_LEVEL_MULTI_SITE  = 'multi-site';
89
        private const LICENSE_LEVEL_UNLIMITED   = 'unlimited';
90
        private const LICENSE_LEVEL_LIFETIME    = 'lifetime';
91
        private const LICENSE_LEVEL_UNKNOWN     = 'unknown';
92

93
        /**
94
         * Determine whether enrollment should use the filtered product ID.
95
         *
96
         * We only use Pro product context when Pro is active and licensed.
97
         *
98
         * @return bool
99
         */
100
        private static function should_use_filtered_product_id_for_enrollment(): bool {
101
                return defined( 'EDACP_VERSION' ) && 'valid' === get_option( 'edacp_license_status' );
8✔
102
        }
103

104
        /**
105
         * Expose the free product ID via a filter so other plugins (e.g. Pro) can
106
         * read it when inferring license type from API response product IDs.
107
         *
108
         * @return int
109
         */
110
        public static function get_free_product_id(): int {
111
                return self::PRODUCT_ID;
2✔
112
        }
113

114
        /**
115
         * Sets up the license page and handlers.
116
         *
117
         * @since 1.xx.x
118
         */
119
        public function init() {
NEW
120
                $connected_services = new ConnectedServicesPage( 'manage_options' );
×
NEW
121
                $connected_services->add_page();
×
122

123
                // Expose the free product ID so the Pro plugin can infer license type from API response product IDs.
NEW
124
                add_filter( 'edac_free_product_id', [ __CLASS__, 'get_free_product_id' ] );
×
125

126
                // Ensure the license options group is registered so options.php allows saves.
NEW
127
                add_action( 'admin_init', [ $this, 'register_license_settings' ] );
×
128

129
                // Admin-post handler for license activate/deactivate.
NEW
130
                add_action( 'admin_post_edac_license', [ $this, 'handle_license_post' ] );
×
131

132
                // Schedule periodic license checks.
NEW
133
                add_action( 'init', [ $this, 'check_license_cron' ] );
×
NEW
134
                add_action( 'edac_check_license_hook', [ $this, 'periodic_check_license' ] );
×
135

136
                // The admin-post handlers for register/unregister buttons.
NEW
137
                add_action( 'admin_post_edac_jwt_register', [ $this, 'handle_jwt_register_post' ] );
×
NEW
138
                add_action( 'admin_post_edac_jwt_unregister', [ $this, 'handle_jwt_unregister_post' ] );
×
139

140
                // When the pro license is deactivated, unregister the site to avoid orphaned registrations.
NEW
141
                add_action( 'edacp_license_deactivated', [ $this, 'handle_site_unregistration' ], 10, 3 );
×
142

143
                // When a Pro license is activated on an already-connected site, refresh registration
144
                // so enrollment context is updated for Pro.
NEW
145
                add_action( 'edacp_license_activated', [ $this, 'handle_pro_license_activation' ], 10, 3 );
×
146

NEW
147
                add_action(
×
NEW
148
                        'in_admin_header',
×
NEW
149
                        function () {
×
150
                                // Display transient-backed admin notices after redirects.
NEW
151
                                add_action( 'admin_notices', [ $this, 'display_admin_notices' ] );
×
NEW
152
                        },
×
NEW
153
                        1000
×
NEW
154
                );
×
155
        }
156

157
        /**
158
         * Register license settings so the edac_license group is allowed by options.php.
159
         *
160
         * @since 1.xx.x
161
         *
162
         * @return void
163
         */
164
        public function register_license_settings() {
NEW
165
                register_setting(
×
NEW
166
                        'edac_license',
×
NEW
167
                        'edacp_license_key',
×
NEW
168
                        [
×
NEW
169
                                'type'              => 'string',
×
NEW
170
                                'sanitize_callback' => 'sanitize_text_field',
×
NEW
171
                        ]
×
NEW
172
                );
×
173

NEW
174
                register_setting(
×
NEW
175
                        'edac_license',
×
NEW
176
                        'edac_license_status',
×
NEW
177
                        [
×
NEW
178
                                'type'              => 'string',
×
NEW
179
                                'sanitize_callback' => 'sanitize_text_field',
×
NEW
180
                        ]
×
NEW
181
                );
×
182

NEW
183
                register_setting(
×
NEW
184
                        'edac_license',
×
NEW
185
                        'edac_license_error',
×
NEW
186
                        [
×
NEW
187
                                'type'              => 'string',
×
NEW
188
                                'sanitize_callback' => 'sanitize_text_field',
×
NEW
189
                        ]
×
NEW
190
                );
×
191
        }
192

193
        /**
194
         * Handle license activate/deactivate from admin-post.
195
         *
196
         * @since 1.xx.x
197
         *
198
         * @return void
199
         */
200
        public function handle_license_post() {
NEW
201
                if ( ! current_user_can( 'manage_options' ) ) {
×
NEW
202
                        wp_die( esc_html__( 'You do not have permission to manage this license.', 'accessibility-checker' ) );
×
203
                }
204

NEW
205
                check_admin_referer( 'edac_license_nonce', 'edac_license_nonce' );
×
206

207
                // Normalize license key from the form.
NEW
208
                if ( isset( $_POST['edacp_license_key'] ) ) {
×
NEW
209
                        $license = sanitize_text_field( wp_unslash( $_POST['edacp_license_key'] ) );
×
NEW
210
                        update_option( 'edacp_license_key', $license );
×
211
                }
212

NEW
213
                if ( isset( $_POST['edac_license_activate'] ) ) {
×
NEW
214
                        $this->activate_license();
×
NEW
215
                } elseif ( isset( $_POST['edac_license_deactivate'] ) ) {
×
NEW
216
                        $this->deactivate_license();
×
217
                }
218

NEW
219
                $redirect = wp_get_referer();
×
NEW
220
                if ( ! $redirect ) {
×
NEW
221
                        $redirect = admin_url();
×
222
                }
NEW
223
                wp_safe_redirect( $redirect );
×
NEW
224
                exit;
×
225
        }
226

227
        /**
228
         * Activate the license via API and store status/error.
229
         *
230
         * @since 1.xx.x
231
         *
232
         * @return void
233
         */
234
        private function activate_license() {
235
                // If pro plugin is enabled with a valid license, it takes precedence.
236
                // Do not allow free license activation to overwrite pro state.
237
                if ( defined( 'EDACP_VERSION' ) && 'valid' === get_option( 'edacp_license_status' ) ) {
2✔
238
                        update_option( 'edac_license_error', __( 'Pro license is active. Please deactivate the Pro license first if you want to use a free license.', 'accessibility-checker' ) );
2✔
239
                        return;
2✔
240
                }
241

NEW
242
                $license = trim( get_option( 'edacp_license_key' ) );
×
NEW
243
                if ( empty( $license ) ) {
×
NEW
244
                        update_option( 'edac_license_error', 'missing' );
×
NEW
245
                        return;
×
246
                }
247

NEW
248
                $api_params = [
×
NEW
249
                        'edd_action'  => 'activate_license',
×
NEW
250
                        'license'     => $license,
×
NEW
251
                        'item_id'     => self::PRODUCT_ID,
×
NEW
252
                        'url'         => home_url(),
×
NEW
253
                        'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production',
×
NEW
254
                        'wp_version'  => get_bloginfo( 'version' ),
×
NEW
255
                        'php_version' => phpversion(),
×
NEW
256
                ];
×
257

NEW
258
                $response = wp_remote_post(
×
NEW
259
                        self::get_api_endpoint(),
×
NEW
260
                        [
×
NEW
261
                                'timeout'   => 15,  // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
×
NEW
262
                                'sslverify' => self::verify_ssl(),
×
NEW
263
                                'body'      => $api_params,
×
NEW
264
                        ]
×
NEW
265
                );
×
266

NEW
267
                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
×
NEW
268
                        $message = is_wp_error( $response ) ? $response->get_error_message() : esc_html__( 'An error occurred, please try again.', 'accessibility-checker' );
×
NEW
269
                        update_option( 'edac_license_error', $message );
×
NEW
270
                        return;
×
271
                }
272

NEW
273
                $license_data = json_decode( wp_remote_retrieve_body( $response ) );
×
NEW
274
                self::store_license_metadata_from_response( $license_data, 'free' );
×
275

NEW
276
                if ( isset( $license_data->error ) ) {
×
NEW
277
                        update_option( 'edac_license_error', $license_data->error );
×
NEW
278
                        update_option( 'edac_license_status', $license_data->license ?? '' );
×
NEW
279
                        return;
×
280
                }
281

NEW
282
                delete_option( 'edac_license_error' );
×
NEW
283
                update_option( 'edac_license_status', $license_data->license ?? '' );
×
284

285
                // Automatically register the site after successful license activation.
NEW
286
                if ( 'valid' === ( $license_data->license ?? '' ) ) {
×
NEW
287
                        $this->handle_site_registration();
×
288
                        // Always clear fallback marker when license recovers to valid state,
289
                        // regardless of whether registration succeeded. License validity and
290
                        // registration status are independent concerns (license = key validity,
291
                        // registration = reports feature). The registration can be retried separately.
NEW
292
                        delete_option( 'edac_fallback_active' );
×
293
                }
294
        }
295

296
        /**
297
         * Clear all license and enrollment state.
298
         *
299
         * Called when deactivating or removing a license to ensure a clean slate.
300
         *
301
         * @return void
302
         */
303
        private static function clear_all_license_state(): void {
304
                delete_option( 'edacp_license_key' );
2✔
305
                delete_option( 'edacp_license_status' );
2✔
306
                delete_option( 'edacp_license_error' );
2✔
307
                delete_option( 'edac_license_status' );
2✔
308
                delete_option( 'edac_license_error' );
2✔
309
                self::clear_stored_license_metadata();
2✔
310
                self::clear_report_connection_state();
2✔
311
                delete_option( 'edac_fallback_active' );
2✔
312
        }
313

314
        /**
315
         * Clear report-connection specific state only.
316
         *
317
         * @return void
318
         */
319
        private static function clear_report_connection_state(): void {
320
                delete_option( 'edac_jwt_public_key' );
10✔
321
                delete_option( 'edac_site_id' );
10✔
322
                delete_option( 'edac_collection_interval_days' );
10✔
323
                delete_option( 'edac_next_collection' );
10✔
324
        }
325

326
        /**
327
         * Clear free-authority license state while preserving active Pro status.
328
         *
329
         * @return void
330
         */
331
        private static function clear_free_disconnect_license_state(): void {
332
                delete_option( 'edacp_license_key' );
2✔
333
                delete_option( 'edac_license_status' );
2✔
334
                delete_option( 'edac_license_error' );
2✔
335
                self::clear_stored_license_metadata();
2✔
336
                delete_option( 'edac_fallback_active' );
2✔
337
        }
338

339
        /**
340
         * Determine whether an unregister action should preserve current license state.
341
         *
342
         * Active Pro licenses should remain active when only disabling reports.
343
         *
344
         * @return bool
345
         */
346
        private static function should_preserve_license_on_unregistration(): bool {
347
                return defined( 'EDACP_VERSION' ) && self::LICENSE_STATUS_VALID === get_option( 'edacp_license_status' );
8✔
348
        }
349

350
        /**
351
         * Deactivate the license via API and always clear local stored values.
352
         *
353
         * @since 1.xx.x
354
         *
355
         * @return void
356
         */
357
        private function deactivate_license() {
358
                $license = trim( get_option( 'edacp_license_key' ) );
2✔
359
                if ( empty( $license ) ) {
2✔
NEW
360
                        self::clear_all_license_state();
×
NEW
361
                        return;
×
362
                }
363

364
                // Best effort unregister: do not block local disconnect on remote failures.
365
                $site_id = (string) get_option( 'edac_site_id' );
2✔
366
                if ( '' !== $site_id ) {
2✔
367
                        self::unregister_site( $site_id, get_site_url(), $license );
2✔
368
                }
369

370
                $api_params = [
2✔
371
                        'edd_action' => 'deactivate_license',
2✔
372
                        'license'    => $license,
2✔
373
                        'item_name'  => rawurlencode( self::PRODUCT_NAME ),
2✔
374
                        'url'        => home_url(),
2✔
375
                ];
2✔
376

377
                wp_remote_post(
2✔
378
                        self::get_api_endpoint(),
2✔
379
                        [
2✔
380
                                'timeout'   => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
2✔
381
                                'sslverify' => self::verify_ssl(),
2✔
382
                                'body'      => $api_params,
2✔
383
                        ]
2✔
384
                );
2✔
385

386
                // Remote deactivation is a best effort. Intentionally clear local
387
                // state regardless of API response so users can always disconnect.
388
                self::clear_all_license_state();
2✔
389
        }
390

391
        /**
392
         * License check
393
         *
394
         * Also includes proactive JWT public key verification as part of key rotation strategy.
395
         *
396
         * Bails early if the pro plugin (EDACP) is enabled to let it handle license checking.
397
         *
398
         * @return void
399
         */
400
        public function periodic_check_license() {
401
                // Guard: Only bail if Pro is active with VALID license.
402
                // This allows fallback when Pro license becomes invalid (expired, disabled, etc).
403
                //
404
                // Safe from race conditions:
405
                // - Once Pro's license status changes from 'valid' to anything else, this guard
406
                // stops bailing and free plugin resumes checking.
407
                // - Both plugins check the same 'edacp_license_status' option atomically
408
                // - Concurrent reads of the same option value are thread-safe in WordPress.
409
                if ( defined( 'EDACP_VERSION' ) && self::LICENSE_STATUS_VALID === get_option( 'edacp_license_status' ) ) {
8✔
NEW
410
                        return;
×
411
                }
412

413
                $license = trim( get_option( 'edacp_license_key' ) );
8✔
414
                if ( ! $license ) {
8✔
NEW
415
                        return;
×
416
                }
417

418
                $api_params = [
8✔
419
                        'edd_action'   => 'check_license',
8✔
420
                        'license'      => $license,
8✔
421
                        'item_id'      => self::PRODUCT_ID,
8✔
422
                        'item_name'    => rawurlencode( self::PRODUCT_NAME ),
8✔
423
                        'url'          => home_url(),
8✔
424
                        'environment'  => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production',
8✔
425
                        'edac_version' => defined( 'EDAC_VERSION' ) ? EDAC_VERSION : '0.0.0',
8✔
426
                        'wp_version'   => get_bloginfo( 'version' ),
8✔
427
                        'php_version'  => phpversion(),
8✔
428
                ];
8✔
429

430
                // Call the custom API.
431
                $response = wp_remote_post(
8✔
432
                        self::get_api_endpoint(),
8✔
433
                        [
8✔
434
                                'timeout'   => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- 15 seconds is needed for now.
8✔
435
                                'sslverify' => self::verify_ssl(),
8✔
436
                                'body'      => $api_params,
8✔
437
                        ]
8✔
438
                );
8✔
439
                if ( is_wp_error( $response ) ) {
8✔
440
                        // this is a silent failure, we should log this or flag it somehow.
NEW
441
                        return;
×
442
                }
443

444
                if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
8✔
445
                        // this is a silent failure, we should log this or flag it somehow.
NEW
446
                        return;
×
447
                }
448

449
                $license_data = json_decode( wp_remote_retrieve_body( $response ) );
8✔
450
                self::store_license_metadata_from_response( $license_data, 'free' );
8✔
451

452
                if ( isset( $license_data->license ) ) {
8✔
453
                        update_option( 'edac_license_status', $license_data->license );
8✔
454
                        if ( 'valid' === $license_data->license ) {
8✔
455
                                // License has recovered to valid, so clear error notices and fallback marker.
456
                                delete_option( 'edac_license_error' );
8✔
457
                                delete_option( 'edacp_license_error' );
8✔
458
                                // Free revalidated successfully after fallback; remove the temporary
459
                                // fallback marker so UI can reflect connected state again.
460
                                delete_option( 'edac_fallback_active' );
8✔
461
                        }
462
                }
463

464
                // Verify and update JWT public key daily before validation fails.
465
                // This ensures the site always has the latest key from the issuer without any downtime.
466
                self::verify_and_update_public_key();
8✔
467
        }
468

469
        /**
470
         * License check cron schedule
471
         *
472
         * @return void
473
         */
474
        public function check_license_cron() {
475
                if ( ! wp_next_scheduled( 'edac_check_license_hook' ) ) {
20✔
NEW
476
                        wp_schedule_event( time(), 'daily', 'edac_check_license_hook' );
×
477
                }
478
        }
479

480
        /**
481
         * Determines whether to verify SSL for licensing requests.
482
         *
483
         * Can be disabled by returning `false` to the `edac_verify_ssl_for_licensing` filter.
484
         *
485
         * @since 1.xx.x
486
         *
487
         * @return bool Whether to verify SSL. Defaults to `true`.
488
         */
489
        public static function verify_ssl() {
490
                return (bool) apply_filters( 'edac_verify_ssl_for_licensing', true );
16✔
491
        }
492

493
        /**
494
         * Get the MyDot API endpoint.
495
         *
496
         * Can be overridden by filtering the value with the `edac_mydot_api_endpoint` filter.
497
         *
498
         * @since 1.xx.x
499
         *
500
         * @return string The API endpoint URL (with protocol). Defaults to `https://my.equalizedigital.com`.
501
         */
502
        public static function get_api_endpoint() {
503
                /**
504
                 * Filters the MyDot API endpoint URL.
505
                 *
506
                 * @since 1.xx.x
507
                 *
508
                 * @param string $default The default or environment-overridden API endpoint URL.
509
                 */
510
                return apply_filters( 'edac_mydot_api_endpoint', self::API_ENDPOINT );
16✔
511
        }
512

513
        /**
514
         * Get the MyDot product ID.
515
         *
516
         * Can be overridden by filtering the value with the `edac_mydot_product_id` filter.
517
         *
518
         * @since 1.xx.x
519
         *
520
         * @return int The product ID. Defaults to 1666.
521
         */
522
        public static function get_product_id(): int {
523
                /**
524
                 * Filters the MyDot product ID.
525
                 *
526
                 * @since 1.xx.x
527
                 *
528
                 * @param int $default The default product ID.
529
                 */
530
                return (int) apply_filters( 'edac_mydot_product_id', self::PRODUCT_ID );
6✔
531
        }
532

533
        /**
534
         * Get the active license key.
535
         *
536
         * Both free and pro plugins store their license key in the same option 'edacp_license_key'.
537
         * The actual product type (free vs pro) is determined by the EDD response item_id at activation
538
         * time and stored in the metadata. This function simply retrieves the key itself.
539
         *
540
         * @return string The license key or empty string if none stored.
541
         *
542
         * @since 1.xx.x
543
         */
544
        public static function get_license_key(): string {
545
                return (string) get_option( 'edacp_license_key', '' );
8✔
546
        }
547

548
        /**
549
         * Handle admin-post for site registration (button on License page).
550
         *
551
         * @since 1.xx.x
552
         *
553
         * @return void
554
         */
555
        public function handle_jwt_register_post() {
NEW
556
                if ( ! current_user_can( 'manage_options' ) ) {
×
NEW
557
                        wp_die( esc_html__( 'You do not have permission to register this site.', 'accessibility-checker' ) );
×
558
                }
NEW
559
                check_admin_referer( 'edac_jwt_register', 'edac_jwt_register_nonce' );
×
NEW
560
                $this->handle_site_registration();
×
NEW
561
                $redirect = wp_get_referer();
×
NEW
562
                if ( ! $redirect ) {
×
NEW
563
                        $redirect = admin_url();
×
564
                }
NEW
565
                wp_safe_redirect( $redirect );
×
NEW
566
                exit;
×
567
        }
568

569
        /**
570
         * Handle admin-post for site unregistration (button on License page).
571
         *
572
         * @since 1.xx.x
573
         *
574
         * @return void
575
         */
576
        public function handle_jwt_unregister_post() {
NEW
577
                if ( ! current_user_can( 'manage_options' ) ) {
×
NEW
578
                        wp_die( esc_html__( 'You do not have permission to unregister this site.', 'accessibility-checker' ) );
×
579
                }
NEW
580
                check_admin_referer( 'edac_jwt_unregister', 'edac_jwt_unregister_nonce' );
×
NEW
581
                $this->handle_site_unregistration();
×
NEW
582
                $redirect = wp_get_referer();
×
NEW
583
                if ( ! $redirect ) {
×
NEW
584
                        $redirect = admin_url();
×
585
                }
NEW
586
                wp_safe_redirect( $redirect );
×
NEW
587
                exit;
×
588
        }
589

590
        /**
591
         * Handle the site registration process including UI feedback.
592
         *
593
         * @since 1.xx.x
594
         *
595
         * @return bool True when registration succeeded and state was saved.
596
         */
597
        private function handle_site_registration(): bool {
NEW
598
                $license_key = self::get_license_key();
×
NEW
599
                if ( empty( $license_key ) ) {
×
NEW
600
                        set_transient(
×
NEW
601
                                $this->get_notice_transient_key(),
×
NEW
602
                                [
×
NEW
603
                                        'type'    => 'error',
×
NEW
604
                                        'message' => __( 'No license key found. Please activate a license before registering your site.', 'accessibility-checker' ),
×
NEW
605
                                ],
×
NEW
606
                                self::NOTICE_TRANSIENT_TTL
×
NEW
607
                        );
×
NEW
608
                        return false;
×
609
                }
NEW
610
                $site_url  = site_url();
×
NEW
611
                $site_name = get_bloginfo( 'name' );
×
612

NEW
613
                $response_data = self::register_site( $license_key, $site_url, $site_name, true, true );
×
NEW
614
                if ( empty( $response_data['success'] ) ) {
×
NEW
615
                        $error_msg = ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while registering the site.', 'accessibility-checker' );
×
NEW
616
                        set_transient(
×
NEW
617
                                $this->get_notice_transient_key(),
×
NEW
618
                                [
×
NEW
619
                                        'type'    => 'error',
×
NEW
620
                                        'message' => $error_msg,
×
NEW
621
                                ],
×
NEW
622
                                self::NOTICE_TRANSIENT_TTL
×
NEW
623
                        );
×
NEW
624
                        return false;
×
625
                }
NEW
626
                if ( isset( $response_data['data'] ) ) {
×
NEW
627
                        $data = $response_data['data'];
×
NEW
628
                        if ( ! empty( $data['jwt_public_key'] ) ) {
×
NEW
629
                                update_option( 'edac_jwt_public_key', $data['jwt_public_key'] );
×
630
                        }
NEW
631
                        if ( ! empty( $data['site_id'] ) ) {
×
NEW
632
                                update_option( 'edac_site_id', $data['site_id'] );
×
633
                        }
NEW
634
                        if ( ! empty( $data['collection_interval_days'] ) ) {
×
NEW
635
                                update_option( 'edac_collection_interval_days', $data['collection_interval_days'] );
×
636
                        }
NEW
637
                        if ( ! empty( $data['next_collection'] ) ) {
×
NEW
638
                                update_option( 'edac_next_collection', $data['next_collection'] );
×
639
                        }
NEW
640
                        set_transient(
×
NEW
641
                                $this->get_notice_transient_key(),
×
NEW
642
                                [
×
NEW
643
                                        'type'    => 'success',
×
NEW
644
                                        'message' => __( 'Site registered successfully. Your site is now configured to use additional accessibility services.', 'accessibility-checker' ),
×
NEW
645
                                ],
×
NEW
646
                                self::NOTICE_TRANSIENT_TTL
×
NEW
647
                        );
×
NEW
648
                        return true;
×
649
                } else {
NEW
650
                        set_transient(
×
NEW
651
                                $this->get_notice_transient_key(),
×
NEW
652
                                [
×
NEW
653
                                        'type'    => 'warning',
×
NEW
654
                                        'message' => __( 'Site registration completed, but the response data was not in the expected format. Some features may not work correctly.', 'accessibility-checker' ),
×
NEW
655
                                ],
×
NEW
656
                                self::NOTICE_TRANSIENT_TTL
×
NEW
657
                        );
×
NEW
658
                        return false;
×
659
                }
660
        }
661

662
        /**
663
         * Refresh enrollment after Pro activation when the site is already connected.
664
         *
665
         * This keeps backend enrollment context aligned on free->pro upgrades without
666
         * requiring users to manually disconnect/reconnect reports.
667
         *
668
         * @param string      $license      Activated license key.
669
         * @param string      $url          Site URL from activation hook.
670
         * @param object|null $license_data Activation response payload.
671
         * @return void
672
         */
673
        public function handle_pro_license_activation( $license = '', $url = '', $license_data = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Hook signature intentionally accepts action args for compatibility.
674
                $site_id = (string) get_option( 'edac_site_id', '' );
2✔
675
                if ( '' === $site_id ) {
2✔
NEW
676
                        return;
×
677
                }
678

679
                $license_key = self::get_license_key();
2✔
680
                if ( '' === $license_key ) {
2✔
NEW
681
                        return;
×
682
                }
683

684
                $response_data = self::register_site( $license_key, site_url(), get_bloginfo( 'name' ), true, true );
2✔
685
                if ( empty( $response_data['success'] ) || empty( $response_data['data'] ) ) {
2✔
NEW
686
                        return;
×
687
                }
688

689
                $data = $response_data['data'];
2✔
690
                if ( ! empty( $data['jwt_public_key'] ) ) {
2✔
691
                        update_option( 'edac_jwt_public_key', $data['jwt_public_key'] );
2✔
692
                }
693
                if ( ! empty( $data['site_id'] ) ) {
2✔
694
                        update_option( 'edac_site_id', $data['site_id'] );
2✔
695
                }
696
                if ( ! empty( $data['collection_interval_days'] ) ) {
2✔
697
                        update_option( 'edac_collection_interval_days', $data['collection_interval_days'] );
2✔
698
                }
699
                if ( ! empty( $data['next_collection'] ) ) {
2✔
700
                        update_option( 'edac_next_collection', $data['next_collection'] );
2✔
701
                }
702
        }
703

704
        /**
705
         * Handle the site unregistration process including UI feedback.
706
         *
707
         * @since 1.xx.x
708
         *
709
         * @param string      $license      Optional license key passed from deactivation hooks.
710
         * @param string      $url          Optional site URL from deactivation hooks.
711
         * @param object|null $license_data Optional license payload from deactivation hooks.
712
         *
713
         * @return void
714
         */
715
        public function handle_site_unregistration( $license = '', $url = '', $license_data = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Hook signature intentionally accepts action args for compatibility.
716
                $preserve_license = self::should_preserve_license_on_unregistration();
8✔
717
                $site_id          = get_option( 'edac_site_id' );
8✔
718
                $license_key      = '' !== (string) $license ? (string) $license : self::get_license_key();
8✔
719
                if ( empty( $site_id ) || empty( $license_key ) ) {
8✔
720
                        // Clear local report connection state even when required data is missing.
721
                        self::clear_report_connection_state();
4✔
722
                        if ( ! $preserve_license ) {
4✔
723
                                // Free disconnect keeps the historical behavior of clearing the key.
724
                                self::clear_free_disconnect_license_state();
2✔
725
                        }
726
                        set_transient(
4✔
727
                                $this->get_notice_transient_key(),
4✔
728
                                [
4✔
729
                                        'type'    => 'error',
4✔
730
                                        'message' => __( 'Unable to unregister site. Required registration data is missing.', 'accessibility-checker' ),
4✔
731
                                ],
4✔
732
                                self::NOTICE_TRANSIENT_TTL
4✔
733
                        );
4✔
734
                        return;
4✔
735
                }
736
                $response_data = self::unregister_site( $site_id, get_site_url(), $license_key );
4✔
737

738
                // Always clear local report state so reports are disabled immediately.
739
                self::clear_report_connection_state();
4✔
740
                if ( ! $preserve_license ) {
4✔
741
                        // Free disconnect keeps the historical behavior of clearing the key,
742
                        // even when the API response is an error.
NEW
743
                        self::clear_free_disconnect_license_state();
×
744
                }
745

746
                if ( empty( $response_data['success'] ) ) {
4✔
747
                        $error_msg = ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while unregistering the site.', 'accessibility-checker' );
2✔
748
                        set_transient(
2✔
749
                                $this->get_notice_transient_key(),
2✔
750
                                [
2✔
751
                                        'type'    => 'error',
2✔
752
                                        'message' => $error_msg,
2✔
753
                                ],
2✔
754
                                self::NOTICE_TRANSIENT_TTL
2✔
755
                        );
2✔
756
                        return;
2✔
757
                }
758
                set_transient(
2✔
759
                        $this->get_notice_transient_key(),
2✔
760
                        [
2✔
761
                                'type'    => 'success',
2✔
762
                                'message' => __( 'Site unregistered successfully. Your site will no longer receive email reports.', 'accessibility-checker' ),
2✔
763
                        ],
2✔
764
                        self::NOTICE_TRANSIENT_TTL
2✔
765
                );
2✔
766
        }
767

768
        /**
769
         * Register a site with the MyDot API.
770
         *
771
         * @since 1.xx.x
772
         *
773
         * @param string $license_key     The license key to register the site with.
774
         * @param string $site_url        The URL of the site to register.
775
         * @param string $site_name       The name of the site to register.
776
         * @param bool   $weekly_reports  Whether to enable weekly reports.
777
         * @param bool   $monthly_reports Whether to enable monthly reports.
778
         *
779
         * @return array The response data from the API.
780
         */
781
        public static function register_site( $license_key, $site_url, $site_name, $weekly_reports = true, $monthly_reports = true ) {
782
                if ( empty( $license_key ) ) {
2✔
NEW
783
                        return [
×
NEW
784
                                'success' => false,
×
NEW
785
                                'message' => __( 'No license key provided.', 'accessibility-checker' ),
×
NEW
786
                        ];
×
787
                }
788
                $request_data = [
2✔
789
                        'site_url'        => $site_url,
2✔
790
                        'site_name'       => $site_name,
2✔
791
                        'license_key'     => $license_key,
2✔
792
                        'weekly_reports'  => $weekly_reports,
2✔
793
                        'monthly_reports' => $monthly_reports,
2✔
794
                ];
2✔
795

796
                if ( self::should_use_filtered_product_id_for_enrollment() ) {
2✔
NEW
797
                        $request_data['product_id'] = self::get_product_id();
×
798
                }
799
                $response = wp_remote_post(
2✔
800
                        self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/register-site',
2✔
801
                        [
2✔
802
                                'headers'     => [ 'Content-Type' => 'application/json' ],
2✔
803
                                'body'        => wp_json_encode( $request_data ),
2✔
804
                                'method'      => 'POST',
2✔
805
                                'data_format' => 'body',
2✔
806
                                'timeout'     => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
2✔
807
                                'sslverify'   => self::verify_ssl(),
2✔
808
                        ]
2✔
809
                );
2✔
810
                if ( is_wp_error( $response ) ) {
2✔
NEW
811
                        return [
×
NEW
812
                                'success' => false,
×
NEW
813
                                'message' => $response->get_error_message(),
×
NEW
814
                        ];
×
815
                }
816
                $response_code = wp_remote_retrieve_response_code( $response );
2✔
817
                $response_body = wp_remote_retrieve_body( $response );
2✔
818
                $response_data = json_decode( $response_body, true );
2✔
819
                if ( 200 !== $response_code || empty( $response_body ) ) {
2✔
NEW
820
                        return [
×
NEW
821
                                'success' => false,
×
NEW
822
                                'message' => ( ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while registering the site.', 'accessibility-checker' ) ),
×
NEW
823
                        ];
×
824
                }
825
                return $response_data;
2✔
826
        }
827

828
        /**
829
         * Unregister a site from the MyDot API.
830
         *
831
         * @since 1.xx.x
832
         *
833
         * @param string $site_id     The site ID for the registered site.
834
         * @param string $site_url    The URL of the site to unregister.
835
         * @param string $license_key The license key associated with the site.
836
         *
837
         * @return array The response data from the API.
838
         */
839
        public static function unregister_site( $site_id, $site_url, $license_key ) {
840
                if ( empty( $site_id ) || empty( $site_url ) || empty( $license_key ) ) {
6✔
NEW
841
                        return [
×
NEW
842
                                'success' => false,
×
NEW
843
                                'message' => __( 'Missing required parameters for unregistration.', 'accessibility-checker' ),
×
NEW
844
                        ];
×
845
                }
846
                $request_data = [
6✔
847
                        'site_id'     => $site_id,
6✔
848
                        'site_url'    => $site_url,
6✔
849
                        'license_key' => $license_key,
6✔
850
                ];
6✔
851

852
                if ( self::should_use_filtered_product_id_for_enrollment() ) {
6✔
853
                        $request_data['product_id'] = self::get_product_id();
6✔
854
                }
855
                $response = wp_remote_post(
6✔
856
                        self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/unregister-site',
6✔
857
                        [
6✔
858
                                'headers'     => [
6✔
859
                                        'Content-Type' => 'application/json',
6✔
860
                                ],
6✔
861
                                'body'        => wp_json_encode( $request_data ),
6✔
862
                                'method'      => 'POST',
6✔
863
                                'data_format' => 'body',
6✔
864
                                'timeout'     => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
6✔
865
                                'sslverify'   => self::verify_ssl(),
6✔
866
                        ]
6✔
867
                );
6✔
868
                if ( is_wp_error( $response ) ) {
6✔
NEW
869
                        return [
×
NEW
870
                                'success' => false,
×
NEW
871
                                'message' => $response->get_error_message(),
×
NEW
872
                        ];
×
873
                }
874
                $response_code = wp_remote_retrieve_response_code( $response );
6✔
875
                $response_body = wp_remote_retrieve_body( $response );
6✔
876
                $response_data = json_decode( $response_body, true );
6✔
877
                if ( 200 !== $response_code || empty( $response_body ) ) {
6✔
878
                        return [
4✔
879
                                'success' => false,
4✔
880
                                'message' => ( ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while unregistering the site.', 'accessibility-checker' ) ),
4✔
881
                        ];
4✔
882
                }
883
                return $response_data;
2✔
884
        }
885

886
        /**
887
         * Get the expected issuer for JWT validation (RFC 8725).
888
         *
889
         * @since 1.xx.x
890
         *
891
         * @return string The issuer URL/identifier.
892
         */
893
        public static function get_jwt_issuer() {
894
                // strip the protocol for issuer comparison.
NEW
895
                return apply_filters( 'edac_jwt_issuer', preg_replace( '#^https?://#', '', self::get_api_endpoint() ) );
×
896
        }
897

898
        /**
899
         * Get the expected audience for JWT validation (RFC 8725).
900
         *
901
         * @since 1.xx.x
902
         *
903
         * @return string The audience identifier (site URL or API endpoint identifier).
904
         */
905
        public static function get_jwt_audience() {
906
                // strip the protocol for audience comparison.
NEW
907
                return apply_filters( 'edac_jwt_audience', preg_replace( '#^https?://#', '', home_url() ) );
×
908
        }
909

910
        /**
911
         * Validate a JWT token using the stored public key (RFC 8725 compliant).
912
         *
913
         * Validates:
914
         * - Token structure (3 parts separated by dots)
915
         * - Header algorithm (RS256)
916
         * - Signature using stored public key
917
         * - Token expiration (exp claim)
918
         * - Issuer (iss claim) per RFC 8725 to prevent token substitution attacks
919
         * - Audience (aud claim) per RFC 8725 to ensure token is for this recipient
920
         * - Not Before (nbf claim) if present
921
         *
922
         * @since 1.xx.x
923
         *
924
         * @param string $token The JWT token to validate.
925
         * @return bool True if the token is valid, false otherwise.
926
         */
927
        public static function validate_jwt_token( $token ) {
NEW
928
                if ( empty( $token ) ) {
×
NEW
929
                        return false;
×
930
                }
NEW
931
                $public_key = get_option( 'edac_jwt_public_key' );
×
NEW
932
                if ( empty( $public_key ) ) {
×
NEW
933
                        return false;
×
934
                }
NEW
935
                $parts = explode( '.', $token );
×
NEW
936
                if ( count( $parts ) !== 3 ) {
×
NEW
937
                        return false;
×
938
                }
NEW
939
                list( $header_b64, $payload_b64, $signature_b64 ) = $parts;
×
940

NEW
941
                $header_json  = self::base64url_decode_strict( $header_b64 );
×
NEW
942
                $payload_json = self::base64url_decode_strict( $payload_b64 );
×
NEW
943
                if ( false === $header_json || false === $payload_json ) {
×
NEW
944
                        return false;
×
945
                }
946

NEW
947
                $header  = json_decode( $header_json, true );
×
NEW
948
                $payload = json_decode( $payload_json, true );
×
NEW
949
                if ( ! $header || ! $payload ) {
×
NEW
950
                        return false;
×
951
                }
952

953
                // Require that aud, iss and exp all exist.
NEW
954
                if ( ! isset( $payload['aud'], $payload['iss'], $payload['exp'] ) ) {
×
NEW
955
                        return false;
×
956
                }
957
                // The exp should be numeric and an int.
NEW
958
                if ( ! is_numeric( $payload['exp'] ) ) {
×
NEW
959
                        return false;
×
960
                }
NEW
961
                $exp = (int) $payload['exp'];
×
962

NEW
963
                $message           = $header_b64 . '.' . $payload_b64;
×
NEW
964
                $signature_decoded = self::base64url_decode_strict( $signature_b64 );
×
NEW
965
                if ( false === $signature_decoded ) {
×
NEW
966
                        return false;
×
967
                }
968

NEW
969
                $algo = $header['alg'] ?? 'RS256';
×
NEW
970
                if ( 'RS256' !== $algo ) {
×
NEW
971
                        return false;
×
972
                }
973

NEW
974
                $public_key_resource = openssl_pkey_get_public( $public_key );
×
NEW
975
                if ( ! $public_key_resource ) {
×
NEW
976
                        return false;
×
977
                }
978

NEW
979
                $verify_result = openssl_verify( $message, $signature_decoded, $public_key_resource, OPENSSL_ALGO_SHA256 );
×
NEW
980
                if ( 1 !== $verify_result ) {
×
NEW
981
                        return false;
×
982
                }
983

NEW
984
                $current_time = time();
×
985
                // Validate expiration (exp claim) - required by RFC 8725.
NEW
986
                if ( $exp < $current_time ) {
×
NEW
987
                        return false;
×
988
                }
989

990
                // RFC 8725: Validate issuer claim to prevent token substitution attacks.
NEW
991
                $expected_iss = self::get_jwt_issuer();
×
NEW
992
                if ( $payload['iss'] !== $expected_iss ) {
×
NEW
993
                        return false;
×
994
                }
995

996
                // RFC 8725: Validate audience claim - if issuer issues JWTs for multiple recipients,
997
                // the JWT must contain an "aud" claim and must be validated.
NEW
998
                $expected_aud = self::get_jwt_audience();
×
NEW
999
                $token_aud    = $payload['aud'];
×
1000
                // aud can be a string or an array of strings per RFC 7519.
NEW
1001
                $aud_list = is_array( $token_aud ) ? $token_aud : [ $token_aud ];
×
NEW
1002
                if ( ! in_array( $expected_aud, $aud_list, true ) ) {
×
NEW
1003
                        return false;
×
1004
                }
1005

1006
                // RFC 8725: Validate not-before claim (nbf) if present.
NEW
1007
                if ( isset( $payload['nbf'] ) ) {
×
NEW
1008
                        if ( ! is_numeric( $payload['nbf'] ) ) {
×
NEW
1009
                                return false;
×
1010
                        }
NEW
1011
                        if ( (int) $payload['nbf'] > $current_time ) {
×
NEW
1012
                                return false;
×
1013
                        }
1014
                }
1015

NEW
1016
                return true;
×
1017
        }
1018

1019
        /**
1020
         * Validate JWT token with reactive fallback.
1021
         *
1022
         * If validation fails, attempt to refresh the public key from the issuer and retry.
1023
         * This handles cases where the issuer rotated keys but the site's cron hasn't run yet.
1024
         *
1025
         * @since 1.xx.x
1026
         *
1027
         * @param string $token The JWT token to validate.
1028
         * @return bool True if valid (either on first try or after key refresh), false otherwise.
1029
         */
1030
        public static function validate_jwt_token_with_fallback( $token ) {
1031
                // Try initial validation.
NEW
1032
                if ( self::validate_jwt_token( $token ) ) {
×
NEW
1033
                        return true;
×
1034
                }
1035

1036
                // Validation failed. Try to refresh the public key from the issuer.
NEW
1037
                if ( self::refresh_public_key_from_issuer() ) {
×
1038
                        // Key was refreshed, retry validation with the new key.
NEW
1039
                        return self::validate_jwt_token( $token );
×
1040
                }
1041

1042
                // Still invalid after refresh attempt.
NEW
1043
                return false;
×
1044
        }
1045

1046
        /**
1047
         * Permission helper for validating JWT token in REST request with fallback (Option 2 + 3).
1048
         *
1049
         * @since 1.xx.x
1050
         *
1051
         * @param \WP_REST_Request $request The REST request object.
1052
         * @return bool True if valid JWT token is present, false otherwise.
1053
         */
1054
        public static function validate_jwt_token_in_request_with_fallback( $request ) {
NEW
1055
                if ( ! $request instanceof \WP_REST_Request ) {
×
NEW
1056
                        return false;
×
1057
                }
1058

1059
                // Extract the JWT token from the Authorization header.
NEW
1060
                $auth_header = $request->get_header( 'Authorization' );
×
NEW
1061
                $parts       = null !== $auth_header ? explode( ' ', $auth_header ) : [];
×
NEW
1062
                if ( ! empty( $auth_header ) ) {
×
NEW
1063
                        if ( count( $parts ) === 2 && 'Bearer' === $parts[0] ) {
×
1064
                                // Use the fallback validator which will refresh key if needed.
NEW
1065
                                return self::validate_jwt_token_with_fallback( $parts[1] );
×
1066
                        }
1067
                }
1068

1069
                // No valid Bearer token found.
NEW
1070
                return false;
×
1071
        }
1072

1073
        /**
1074
         * Check if stored JWT public key needs to be updated from a fresh registration.
1075
         *
1076
         * Called after successful site registration to verify the stored key is current.
1077
         * If the stored key doesn't match what the issuer sent, it's already been rotated.
1078
         *
1079
         * Uses a simple GET request since public keys don't require authentication.
1080
         *
1081
         * @since 1.xx.x
1082
         *
1083
         * @return bool True if public key was updated or is current, false on error.
1084
         */
1085
        public static function verify_and_update_public_key() {
1086
                $stored_key = get_option( 'edac_jwt_public_key' );
8✔
1087

1088
                if ( empty( $stored_key ) ) {
8✔
1089
                        return false;
8✔
1090
                }
1091

1092
                // Make a lightweight GET request for the current public key.
NEW
1093
                $response = self::safe_remote_get( self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/public-key' );
×
1094

NEW
1095
                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
×
NEW
1096
                        return false;
×
1097
                }
1098

NEW
1099
                $data = json_decode( wp_remote_retrieve_body( $response ), true );
×
1100

1101
                // If issuer returned a new public key, store it immediately.
NEW
1102
                if ( ! empty( $data['jwt_public_key'] ) && $data['jwt_public_key'] !== $stored_key ) {
×
NEW
1103
                        update_option( 'edac_jwt_public_key', $data['jwt_public_key'] );
×
NEW
1104
                        return true; // Key was updated.
×
1105
                }
1106

NEW
1107
                return true; // Key is current.
×
1108
        }
1109

1110
        /**
1111
         * Attempt to update the public key from API on failed JWT validation.
1112
         *
1113
         * If JWT validation fails, this optional step re-requests the public key
1114
         * from the issuer. Useful if the issuer rotated keys but the site hasn't
1115
         * refreshed them yet.
1116
         *
1117
         * This is called AFTER a JWT fails validation, so only use as a fallback
1118
         * to avoid constant API calls.
1119
         *
1120
         * Uses a simple GET request since public keys don't require authentication.
1121
         *
1122
         * @since 1.xx.x
1123
         *
1124
         * @return bool True if key was retrieved and stored, false otherwise.
1125
         */
1126
        public static function refresh_public_key_from_issuer() {
NEW
1127
                $response = self::safe_remote_get( self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/get-public-key' );
×
1128

NEW
1129
                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
×
NEW
1130
                        return false;
×
1131
                }
1132

NEW
1133
                $data = json_decode( wp_remote_retrieve_body( $response ), true );
×
NEW
1134
                if ( ! empty( $data['public_key'] ) ) {
×
NEW
1135
                        update_option( 'edac_jwt_public_key', $data['public_key'] );
×
NEW
1136
                        return true;
×
1137
                }
1138

NEW
1139
                return false;
×
1140
        }
1141

1142
        /**
1143
         * Perform a safe GET request compatible with VIP and non-VIP environments.
1144
         *
1145
         * Uses vip_safe_wp_remote_get() if available, otherwise falls back to wp_remote_get().
1146
         *
1147
         * @param string $url  The URL to request.
1148
         * @param array  $args Optional request args.
1149
         * @return array|\WP_Error Response array or WP_Error on failure.
1150
         */
1151
        private static function safe_remote_get( string $url, array $args = [] ) {
NEW
1152
                $defaults = [
×
NEW
1153
                        'timeout'   => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
×
NEW
1154
                        'sslverify' => self::verify_ssl(),
×
NEW
1155
                ];
×
NEW
1156
                $args     = wp_parse_args( $args, $defaults );
×
1157

NEW
1158
                if ( function_exists( 'vip_safe_wp_remote_get' ) ) {
×
NEW
1159
                        $timeout     = isset( $args['timeout'] ) ? max( 1, min( 5, (int) $args['timeout'] ) ) : 5;
×
NEW
1160
                        $retry_count = isset( $args['retry'] ) ? (int) $args['retry'] : 10;
×
NEW
1161
                        return vip_safe_wp_remote_get( $url, '', 3, $timeout, $retry_count, $args );
×
1162
                }
1163

NEW
1164
                return wp_remote_get( $url, $args ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- fallback for non-VIP environments.
×
1165
        }
1166

1167
        /**
1168
         * Display transient-based admin notices for the current user.
1169
         */
1170
        public function display_admin_notices() {
NEW
1171
                $key    = $this->get_notice_transient_key();
×
NEW
1172
                $notice = get_transient( $key );
×
1173

NEW
1174
                if ( empty( $notice['type'] ) || empty( $notice['message'] ) ) {
×
NEW
1175
                        return;
×
1176
                }
NEW
1177
                delete_transient( $key );
×
1178

NEW
1179
                $allowed_types = [ 'success', 'error', 'warning', 'info' ];
×
NEW
1180
                $type          = in_array( $notice['type'], $allowed_types, true ) ? $notice['type'] : 'info';
×
NEW
1181
                $message       = is_string( $notice['message'] ) ? $notice['message'] : '';
×
NEW
1182
                if ( '' === $message ) {
×
NEW
1183
                        return;
×
1184
                }
1185

NEW
1186
                printf(
×
NEW
1187
                        '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
×
NEW
1188
                        esc_attr( $type ),
×
NEW
1189
                        esc_html( $message )
×
NEW
1190
                );
×
1191
        }
1192

1193
        /**
1194
         * Build the transient key for connector notices.
1195
         *
1196
         * @param int|null $user_id Optional user ID; defaults to current user.
1197
         *
1198
         * @return string
1199
         */
1200
        private function get_notice_transient_key( $user_id = null ) {
1201
                $user_id = null === $user_id ? get_current_user_id() : (int) $user_id;
8✔
1202

1203
                return 'edac_connector_notice_' . absint( $user_id );
8✔
1204
        }
1205

1206
        /**
1207
         * Strict Base64URL decode that returns false on invalid input.
1208
         *
1209
         * @since 1.xx.x
1210
         *
1211
         * @param string $b64url The Base64URL encoded string.
1212
         * @return string|false The decoded string, or false on failure.
1213
         */
1214
        private static function base64url_decode_strict( string $b64url ) {
NEW
1215
                $b64 = strtr( $b64url, '-_', '+/' );
×
NEW
1216
                $pad = strlen( $b64 ) % 4;
×
NEW
1217
                if ( $pad ) {
×
NEW
1218
                        $b64 .= str_repeat( '=', 4 - $pad );
×
1219
                }
NEW
1220
                return base64_decode( $b64, true );
×
1221
        }
1222

1223
        /**
1224
         * Infer license metadata from an EDD response.
1225
         *
1226
         * Determines the license type (free/pro), level (single-site/multi-site/unlimited/lifetime),
1227
         * and formats response fields for storage.
1228
         *
1229
         * Primary inference uses product ID (most reliable when free/pro have distinct IDs).
1230
         * Secondary fallback uses item_name string matching.
1231
         * Tertiary fallback uses the source context (e.g., 'free', 'pro').
1232
         *
1233
         * @param object|array $license_data EDD response payload.
1234
         * @param string       $source       Activation/check source context ('free' or 'pro').
1235
         * @return array Inferred metadata with keys: type, level, item_id, item_name, expires, license_limit, site_count, activations_left, last_response_at.
1236
         *
1237
         * @since 1.xx.x
1238
         */
1239
        public static function infer_license_metadata_from_response( $license_data, string $source ): array {
1240
                if ( ! is_object( $license_data ) && ! is_array( $license_data ) ) {
18✔
NEW
1241
                        return [
×
NEW
1242
                                'type'             => self::LICENSE_TYPE_UNKNOWN,
×
NEW
1243
                                'level'            => self::LICENSE_LEVEL_UNKNOWN,
×
NEW
1244
                                'item_id'          => 0,
×
NEW
1245
                                'item_name'        => '',
×
NEW
1246
                                'expires'          => '',
×
NEW
1247
                                'license_limit'    => '',
×
NEW
1248
                                'site_count'       => '',
×
NEW
1249
                                'activations_left' => '',
×
NEW
1250
                                'last_response_at' => time(),
×
NEW
1251
                        ];
×
1252
                }
1253

1254
                $data = is_object( $license_data ) ? get_object_vars( $license_data ) : $license_data;
18✔
1255

1256
                // Validate response has at least basic structure (item_id or item_name).
1257
                if ( empty( $data['item_id'] ) && empty( $data['item_name'] ) ) {
18✔
1258
                        // Incomplete response; use source as last resort.
NEW
1259
                        return [
×
NEW
1260
                                'type'             => in_array( $source, [ self::LICENSE_TYPE_FREE, self::LICENSE_TYPE_PRO ], true ) ? $source : self::LICENSE_TYPE_UNKNOWN,
×
NEW
1261
                                'level'            => self::LICENSE_LEVEL_UNKNOWN,
×
NEW
1262
                                'item_id'          => 0,
×
NEW
1263
                                'item_name'        => '',
×
NEW
1264
                                'expires'          => '',
×
NEW
1265
                                'license_limit'    => '',
×
NEW
1266
                                'site_count'       => '',
×
NEW
1267
                                'activations_left' => '',
×
NEW
1268
                                'last_response_at' => time(),
×
NEW
1269
                        ];
×
1270
                }
1271

1272
                $item_name = sanitize_text_field( (string) ( $data['item_name'] ?? '' ) );
18✔
1273
                $item_id   = absint( $data['item_id'] ?? 0 );
18✔
1274
                $limit_raw = $data['license_limit'] ?? '';
18✔
1275

1276
                // Primary: infer from product ID in response — most reliable when free/pro have distinct IDs.
1277
                $type           = self::LICENSE_TYPE_UNKNOWN;
18✔
1278
                $pro_product_id = (int) apply_filters( 'edac_pro_product_id', 0 );
18✔
1279
                if ( $item_id > 0 ) {
18✔
1280
                        if ( self::PRODUCT_ID === $item_id ) {
16✔
1281
                                $type = self::LICENSE_TYPE_FREE;
14✔
1282
                        } elseif ( $pro_product_id > 0 && $pro_product_id === $item_id ) {
2✔
1283
                                // Inferred as Pro because Pro's product ID filter matched.
1284
                                $type = self::LICENSE_TYPE_PRO;
2✔
1285
                        }
1286
                }
1287

1288
                // Secondary: infer from item_name string match.
1289
                if ( self::LICENSE_TYPE_UNKNOWN === $type && '' !== $item_name ) {
18✔
1290
                        $item_name_normalized = strtolower( $item_name );
2✔
1291
                        if ( false !== strpos( $item_name_normalized, 'pro' ) ) {
2✔
1292
                                $type = self::LICENSE_TYPE_PRO;
2✔
NEW
1293
                        } elseif ( false !== strpos( $item_name_normalized, 'free' ) ) {
×
NEW
1294
                                $type = self::LICENSE_TYPE_FREE;
×
1295
                        }
1296
                }
1297

1298
                // Fallback: use source context.
1299
                if ( self::LICENSE_TYPE_UNKNOWN === $type ) {
18✔
NEW
1300
                        $type = in_array( $source, [ self::LICENSE_TYPE_FREE, self::LICENSE_TYPE_PRO ], true ) ? $source : self::LICENSE_TYPE_UNKNOWN;
×
1301
                }
1302

1303
                $level = self::LICENSE_LEVEL_UNKNOWN;
18✔
1304
                if ( is_numeric( $limit_raw ) ) {
18✔
1305
                        $limit = (int) $limit_raw;
16✔
1306
                        if ( 0 === $limit ) {
16✔
1307
                                $level = self::LICENSE_LEVEL_UNLIMITED; // EDD uses 0 to mean no activation limit.
2✔
1308
                        } elseif ( 1 === $limit ) {
14✔
1309
                                $level = self::LICENSE_LEVEL_SINGLE_SITE;
10✔
1310
                        } elseif ( $limit > 1 ) {
4✔
1311
                                $level = self::LICENSE_LEVEL_MULTI_SITE;
16✔
1312
                        }
1313
                } elseif ( is_string( $limit_raw ) ) {
2✔
1314
                        $limit_normalized = strtolower( trim( $limit_raw ) );
2✔
1315
                        if ( in_array( $limit_normalized, [ self::LICENSE_LEVEL_LIFETIME, self::LICENSE_LEVEL_UNLIMITED ], true ) ) {
2✔
1316
                                $level = $limit_normalized;
2✔
1317
                        }
1318
                }
1319

1320
                return [
18✔
1321
                        'type'             => $type,
18✔
1322
                        'level'            => $level,
18✔
1323
                        'item_id'          => $item_id,
18✔
1324
                        'item_name'        => $item_name,
18✔
1325
                        'expires'          => sanitize_text_field( (string) ( $data['expires'] ?? '' ) ),
18✔
1326
                        'license_limit'    => sanitize_text_field( (string) $limit_raw ),
18✔
1327
                        'site_count'       => sanitize_text_field( (string) ( $data['site_count'] ?? '' ) ),
18✔
1328
                        'activations_left' => sanitize_text_field( (string) ( $data['activations_left'] ?? '' ) ),
18✔
1329
                        'last_response_at' => time(),
18✔
1330
                ];
18✔
1331
        }
1332

1333
        /**
1334
         * Persist inferred license metadata from an EDD response.
1335
         *
1336
         * @param object|array|null $license_data EDD response payload.
1337
         * @param string            $source       Activation/check source context.
1338
         * @return void
1339
         */
1340
        private static function store_license_metadata_from_response( $license_data, string $source ): void {
1341
                $metadata = self::infer_license_metadata_from_response( $license_data, $source );
18✔
1342
                update_option( self::LICENSE_METADATA_OPTION, $metadata );
18✔
1343
        }
1344

1345
        /**
1346
         * Clear stored inferred license metadata.
1347
         *
1348
         * @return void
1349
         */
1350
        private static function clear_stored_license_metadata(): void {
1351
                delete_option( self::LICENSE_METADATA_OPTION );
6✔
1352
        }
1353
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc