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

equalizedigital / accessibility-checker / 25881928913

14 May 2026 07:49PM UTC coverage: 57.657% (+0.1%) from 57.515%
25881928913

push

github

web-flow
Merge pull request #1679 from equalizedigital/william/add-extra-compatibility-checking-info

Add a SystemInfo class for getting theme, plugin and env type

42 of 54 new or added lines in 2 files covered. (77.78%)

1 existing line in 1 file now uncovered.

5538 of 9605 relevant lines covered (57.66%)

4.58 hits per line

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

47.77
/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
use EqualizeDigital\AccessibilityChecker\SystemInfo\SystemInfo;
15

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

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

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

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

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

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

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

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

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

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

105
        /**
106
         * Determine whether Pro should be the sole authority for license checks.
107
         *
108
         * Pro is authoritative whenever the Pro plugin is loaded and a non-empty
109
         * license key is present. Free license checks are fully suppressed in this
110
         * state so that both plugins cannot write conflicting status values to the
111
         * database simultaneously. Pro's own cron handles all revalidation; Free
112
         * never needs to re-run alongside it.
113
         *
114
         * @return bool
115
         */
116
        private static function is_pro_license_check_active(): bool {
117
                if ( ! defined( 'EDACP_VERSION' ) ) {
50✔
118
                        return false;
6✔
119
                }
120

121
                return '' !== trim( (string) get_option( 'edacp_license_key', '' ) );
44✔
122
        }
123

124
        /**
125
         * Expose the free product ID via a filter so other plugins (e.g. Pro) can
126
         * read it when inferring license type from API response product IDs.
127
         *
128
         * @return int
129
         */
130
        public static function get_free_product_id(): int {
131
                return self::PRODUCT_ID;
2✔
132
        }
133

134
        /**
135
         * Sets up the license page and handlers.
136
         *
137
         * @since 1.xx.x
138
         */
139
        public function init() {
140
                $connected_services = new ConnectedServicesPage( 'manage_options' );
×
141
                $connected_services->add_page();
×
142

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

146
                // Ensure the license options group is registered so options.php allows saves.
147
                add_action( 'admin_init', [ $this, 'register_license_settings' ] );
×
148

149
                // Admin-post handler for license activate/deactivate.
150
                add_action( 'admin_post_edac_license', [ $this, 'handle_license_post' ] );
×
151

152
                // Schedule periodic license checks.
153
                add_action( 'init', [ $this, 'check_license_cron' ] );
×
154
                add_action( 'edac_check_license_hook', [ $this, 'periodic_check_license' ] );
×
155

156
                // The admin-post handlers for register/unregister buttons.
157
                add_action( 'admin_post_edac_jwt_register', [ $this, 'handle_jwt_register_post' ] );
×
158
                add_action( 'admin_post_edac_jwt_unregister', [ $this, 'handle_jwt_unregister_post' ] );
×
159

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

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

167
                add_action(
×
168
                        'in_admin_header',
×
169
                        function () {
×
170
                                // Display transient-backed admin notices after redirects.
171
                                add_action( 'admin_notices', [ $this, 'display_admin_notices' ] );
×
172
                        },
×
173
                        1000
×
174
                );
×
175
        }
176

177
        /**
178
         * Register license settings so the edac_license group is allowed by options.php.
179
         *
180
         * @since 1.xx.x
181
         *
182
         * @return void
183
         */
184
        public function register_license_settings() {
185
                register_setting(
×
186
                        'edac_license',
×
187
                        'edacp_license_key',
×
188
                        [
×
189
                                'type'              => 'string',
×
190
                                'sanitize_callback' => 'sanitize_text_field',
×
191
                        ]
×
192
                );
×
193

194
                register_setting(
×
195
                        'edac_license',
×
196
                        'edac_license_status',
×
197
                        [
×
198
                                'type'              => 'string',
×
199
                                'sanitize_callback' => 'sanitize_text_field',
×
200
                        ]
×
201
                );
×
202

203
                register_setting(
×
204
                        'edac_license',
×
205
                        'edac_license_error',
×
206
                        [
×
207
                                'type'              => 'string',
×
208
                                'sanitize_callback' => 'sanitize_text_field',
×
209
                        ]
×
210
                );
×
211
        }
212

213
        /**
214
         * Handle license activate/deactivate from admin-post.
215
         *
216
         * @since 1.xx.x
217
         *
218
         * @return void
219
         */
220
        public function handle_license_post() {
221
                if ( ! current_user_can( 'manage_options' ) ) {
×
222
                        wp_die( esc_html__( 'You do not have permission to manage this license.', 'accessibility-checker' ) );
×
223
                }
224

225
                check_admin_referer( 'edac_license_nonce', 'edac_license_nonce' );
×
226

227
                // Normalize license key from the form.
228
                if ( isset( $_POST['edacp_license_key'] ) ) {
×
229
                        $license = sanitize_text_field( wp_unslash( $_POST['edacp_license_key'] ) );
×
230
                        update_option( 'edacp_license_key', $license );
×
231
                }
232

233
                if ( isset( $_POST['edac_license_activate'] ) ) {
×
234
                        $this->activate_license();
×
235
                } elseif ( isset( $_POST['edac_license_deactivate'] ) ) {
×
236
                        $this->deactivate_license();
×
237
                }
238

239
                $redirect = wp_get_referer();
×
240
                if ( ! $redirect ) {
×
241
                        $redirect = admin_url();
×
242
                }
243
                wp_safe_redirect( $redirect );
×
244
                exit;
×
245
        }
246

247
        /**
248
         * Activate the license via API and store status/error.
249
         *
250
         * @since 1.xx.x
251
         *
252
         * @return void
253
         */
254
        private function activate_license() {
255
                // Pro is authoritative whenever its license check flow is active.
256
                if ( self::is_pro_license_check_active() ) {
6✔
257
                        update_option( 'edac_license_error', __( 'Pro license management is active. Please manage your license from Accessibility Checker Pro.', 'accessibility-checker' ) );
6✔
258
                        return;
6✔
259
                }
260

261
                $license = trim( get_option( 'edacp_license_key' ) );
×
262
                if ( empty( $license ) ) {
×
263
                        update_option( 'edac_license_error', 'missing' );
×
264
                        return;
×
265
                }
266

267
                $api_params = [
×
NEW
268
                        'edd_action' => 'activate_license',
×
NEW
269
                        'license'    => $license,
×
NEW
270
                        'item_id'    => self::PRODUCT_ID,
×
NEW
271
                        'url'        => home_url(),
×
UNCOV
272
                ];
×
273

NEW
274
                $api_params = array_merge( $api_params, SystemInfo::get_license_request_context() );
×
275

276
                $response = wp_remote_post(
×
277
                        self::get_api_endpoint(),
×
278
                        [
×
279
                                'timeout'   => 15,  // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
×
280
                                'sslverify' => self::verify_ssl(),
×
281
                                'body'      => $api_params,
×
282
                        ]
×
283
                );
×
284

285
                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
×
286
                        $message = is_wp_error( $response ) ? $response->get_error_message() : esc_html__( 'An error occurred, please try again.', 'accessibility-checker' );
×
287
                        update_option( 'edac_license_error', $message );
×
288
                        return;
×
289
                }
290

291
                $license_data = json_decode( wp_remote_retrieve_body( $response ) );
×
292
                self::store_license_metadata_from_response( $license_data, 'free' );
×
293

294
                if ( isset( $license_data->error ) ) {
×
295
                        update_option( 'edac_license_error', $license_data->error );
×
296
                        update_option( 'edac_license_status', $license_data->license ?? '' );
×
297
                        return;
×
298
                }
299

300
                delete_option( 'edac_license_error' );
×
301
                update_option( 'edac_license_status', $license_data->license ?? '' );
×
302

303
                // Automatically register the site after successful license activation.
304
                if ( 'valid' === ( $license_data->license ?? '' ) ) {
×
305
                        $this->handle_site_registration();
×
306
                        // Always clear fallback marker when license recovers to valid state,
307
                        // regardless of whether registration succeeded. License validity and
308
                        // registration status are independent concerns (license = key validity,
309
                        // registration = reports feature). The registration can be retried separately.
310
                        delete_option( 'edac_fallback_active' );
×
311
                }
312
        }
313

314
        /**
315
         * Clear all license and enrollment state.
316
         *
317
         * Called when deactivating or removing a license to ensure a clean slate.
318
         *
319
         * @return void
320
         */
321
        private static function clear_all_license_state(): void {
322
                delete_option( 'edacp_license_key' );
2✔
323
                delete_option( 'edacp_license_status' );
2✔
324
                delete_option( 'edacp_license_error' );
2✔
325
                delete_option( 'edac_license_status' );
2✔
326
                delete_option( 'edac_license_error' );
2✔
327
                self::clear_stored_license_metadata();
2✔
328
                self::clear_report_connection_state();
2✔
329
                delete_option( 'edac_fallback_active' );
2✔
330
        }
331

332
        /**
333
         * Clear report-connection specific state only.
334
         *
335
         * @return void
336
         */
337
        private static function clear_report_connection_state(): void {
338
                delete_option( 'edac_jwt_public_key' );
10✔
339
                delete_option( 'edac_site_id' );
10✔
340
                delete_option( 'edac_collection_interval_days' );
10✔
341
                delete_option( 'edac_next_collection' );
10✔
342
        }
343

344
        /**
345
         * Clear free-authority license state while preserving active Pro status.
346
         *
347
         * @return void
348
         */
349
        private static function clear_free_disconnect_license_state(): void {
350
                delete_option( 'edacp_license_key' );
2✔
351
                delete_option( 'edac_license_status' );
2✔
352
                delete_option( 'edac_license_error' );
2✔
353
                self::clear_stored_license_metadata();
2✔
354
                delete_option( 'edac_fallback_active' );
2✔
355
        }
356

357
        /**
358
         * Determine whether an unregister action should preserve current license state.
359
         *
360
         * Active Pro licenses should remain active when only disabling reports.
361
         *
362
         * @return bool
363
         */
364
        private static function should_preserve_license_on_unregistration(): bool {
365
                return defined( 'EDACP_VERSION' ) && self::LICENSE_STATUS_VALID === get_option( 'edacp_license_status' );
8✔
366
        }
367

368
        /**
369
         * Deactivate the license via API and always clear local stored values.
370
         *
371
         * @since 1.xx.x
372
         *
373
         * @return void
374
         */
375
        private function deactivate_license() {
376
                $license = trim( get_option( 'edacp_license_key' ) );
2✔
377
                if ( empty( $license ) ) {
2✔
378
                        self::clear_all_license_state();
×
379
                        return;
×
380
                }
381

382
                // Best effort unregister: do not block local disconnect on remote failures.
383
                $site_id = (string) get_option( 'edac_site_id' );
2✔
384
                if ( '' !== $site_id ) {
2✔
385
                        self::unregister_site( $site_id, get_site_url(), $license );
2✔
386
                }
387

388
                $api_params = [
2✔
389
                        'edd_action' => 'deactivate_license',
2✔
390
                        'license'    => $license,
2✔
391
                        'item_name'  => rawurlencode( self::PRODUCT_NAME ),
2✔
392
                        'url'        => home_url(),
2✔
393
                ];
2✔
394

395
                wp_remote_post(
2✔
396
                        self::get_api_endpoint(),
2✔
397
                        [
2✔
398
                                'timeout'   => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
2✔
399
                                'sslverify' => self::verify_ssl(),
2✔
400
                                'body'      => $api_params,
2✔
401
                        ]
2✔
402
                );
2✔
403

404
                // Remote deactivation is a best effort. Intentionally clear local
405
                // state regardless of API response so users can always disconnect.
406
                self::clear_all_license_state();
2✔
407
        }
408

409
        /**
410
         * License check
411
         *
412
         * Also includes proactive JWT public key verification as part of key rotation strategy.
413
         *
414
         * Bails early if the pro plugin (EDACP) is enabled to let it handle license checking.
415
         *
416
         * @return void
417
         */
418
        public function periodic_check_license() {
419
                // Pro is authoritative whenever its own license check flow is active.
420
                if ( self::is_pro_license_check_active() ) {
20✔
421
                        return;
12✔
422
                }
423

424
                $license = trim( get_option( 'edacp_license_key' ) );
8✔
425
                if ( ! $license ) {
8✔
426
                        return;
4✔
427
                }
428

429
                $api_params = [
4✔
430
                        'edd_action'   => 'check_license',
4✔
431
                        'license'      => $license,
4✔
432
                        'item_id'      => self::PRODUCT_ID,
4✔
433
                        'item_name'    => rawurlencode( self::PRODUCT_NAME ),
4✔
434
                        'url'          => home_url(),
4✔
435
                        'edac_version' => defined( 'EDAC_VERSION' ) ? EDAC_VERSION : '0.0.0',
4✔
436
                ];
4✔
437

438
                $api_params = array_merge( $api_params, SystemInfo::get_license_request_context() );
4✔
439

440
                // Call the custom API.
441
                $response = wp_remote_post(
4✔
442
                        self::get_api_endpoint(),
4✔
443
                        [
4✔
444
                                'timeout'   => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- 15 seconds is needed for now.
4✔
445
                                'sslverify' => self::verify_ssl(),
4✔
446
                                'body'      => $api_params,
4✔
447
                        ]
4✔
448
                );
4✔
449
                if ( is_wp_error( $response ) ) {
4✔
450
                        // this is a silent failure, we should log this or flag it somehow.
451
                        return;
×
452
                }
453

454
                if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
4✔
455
                        // this is a silent failure, we should log this or flag it somehow.
456
                        return;
×
457
                }
458

459
                $license_data = json_decode( wp_remote_retrieve_body( $response ) );
4✔
460
                self::store_license_metadata_from_response( $license_data, 'free' );
4✔
461

462
                if ( isset( $license_data->license ) ) {
4✔
463
                        update_option( 'edac_license_status', $license_data->license );
4✔
464
                        if ( 'valid' === $license_data->license ) {
4✔
465
                                // License has recovered to valid, so clear error notices and fallback marker.
466
                                delete_option( 'edac_license_error' );
4✔
467
                                delete_option( 'edacp_license_error' );
4✔
468
                                // Free revalidated successfully after fallback; remove the temporary
469
                                // fallback marker so UI can reflect connected state again.
470
                                delete_option( 'edac_fallback_active' );
4✔
471
                        } elseif ( 'expired' === $license_data->license ) {
×
472
                                // License expired: set the error option so admin notices fire on the next page load.
473
                                update_option( 'edac_license_error', 'expired' );
×
474
                        }
475
                }
476

477
                // Verify and update JWT public key daily before validation fails.
478
                // This ensures the site always has the latest key from the issuer without any downtime.
479
                self::verify_and_update_public_key();
4✔
480
        }
481

482
        /**
483
         * License check cron schedule
484
         *
485
         * @return void
486
         */
487
        public function check_license_cron() {
488
                if ( self::is_pro_license_check_active() ) {
24✔
489
                        wp_clear_scheduled_hook( 'edac_check_license_hook' );
2✔
490
                        return;
2✔
491
                }
492

493
                if ( ! wp_next_scheduled( 'edac_check_license_hook' ) ) {
22✔
494
                        wp_schedule_event( time(), 'daily', 'edac_check_license_hook' );
2✔
495
                }
496
        }
497

498
        /**
499
         * Determines whether to verify SSL for licensing requests.
500
         *
501
         * Can be disabled by returning `false` to the `edac_verify_ssl_for_licensing` filter.
502
         *
503
         * @since 1.xx.x
504
         *
505
         * @return bool Whether to verify SSL. Defaults to `true`.
506
         */
507
        public static function verify_ssl() {
508
                return (bool) apply_filters( 'edac_verify_ssl_for_licensing', true );
14✔
509
        }
510

511
        /**
512
         * Get the MyDot API endpoint.
513
         *
514
         * Can be overridden by filtering the value with the `edac_mydot_api_endpoint` filter.
515
         *
516
         * @since 1.xx.x
517
         *
518
         * @return string The API endpoint URL (with protocol). Defaults to `https://my.equalizedigital.com`.
519
         */
520
        public static function get_api_endpoint() {
521
                /**
522
                 * Filters the MyDot API endpoint URL.
523
                 *
524
                 * @since 1.xx.x
525
                 *
526
                 * @param string $default The default or environment-overridden API endpoint URL.
527
                 */
528
                return apply_filters( 'edac_mydot_api_endpoint', self::API_ENDPOINT );
14✔
529
        }
530

531
        /**
532
         * Get the MyDot product ID.
533
         *
534
         * Can be overridden by filtering the value with the `edac_mydot_product_id` filter.
535
         *
536
         * @since 1.xx.x
537
         *
538
         * @return int The product ID. Defaults to 1666.
539
         */
540
        public static function get_product_id(): int {
541
                /**
542
                 * Filters the MyDot product ID.
543
                 *
544
                 * @since 1.xx.x
545
                 *
546
                 * @param int $default The default product ID.
547
                 */
548
                return (int) apply_filters( 'edac_mydot_product_id', self::PRODUCT_ID );
6✔
549
        }
550

551
        /**
552
         * Get the active license key.
553
         *
554
         * Both free and pro plugins store their license key in the same option 'edacp_license_key'.
555
         * The actual product type (free vs pro) is determined by the EDD response item_id at activation
556
         * time and stored in the metadata. This function simply retrieves the key itself.
557
         *
558
         * @return string The license key or empty string if none stored.
559
         *
560
         * @since 1.xx.x
561
         */
562
        public static function get_license_key(): string {
563
                return (string) get_option( 'edacp_license_key', '' );
8✔
564
        }
565

566
        /**
567
         * Handle admin-post for site registration (button on License page).
568
         *
569
         * @since 1.xx.x
570
         *
571
         * @return void
572
         */
573
        public function handle_jwt_register_post() {
574
                if ( ! current_user_can( 'manage_options' ) ) {
×
575
                        wp_die( esc_html__( 'You do not have permission to register this site.', 'accessibility-checker' ) );
×
576
                }
577
                check_admin_referer( 'edac_jwt_register', 'edac_jwt_register_nonce' );
×
578
                $this->handle_site_registration();
×
579
                $redirect = wp_get_referer();
×
580
                if ( ! $redirect ) {
×
581
                        $redirect = admin_url();
×
582
                }
583
                wp_safe_redirect( $redirect );
×
584
                exit;
×
585
        }
586

587
        /**
588
         * Handle admin-post for site unregistration (button on License page).
589
         *
590
         * @since 1.xx.x
591
         *
592
         * @return void
593
         */
594
        public function handle_jwt_unregister_post() {
595
                if ( ! current_user_can( 'manage_options' ) ) {
×
596
                        wp_die( esc_html__( 'You do not have permission to unregister this site.', 'accessibility-checker' ) );
×
597
                }
598
                check_admin_referer( 'edac_jwt_unregister', 'edac_jwt_unregister_nonce' );
×
599
                $this->handle_site_unregistration();
×
600
                $redirect = wp_get_referer();
×
601
                if ( ! $redirect ) {
×
602
                        $redirect = admin_url();
×
603
                }
604
                wp_safe_redirect( $redirect );
×
605
                exit;
×
606
        }
607

608
        /**
609
         * Handle the site registration process including UI feedback.
610
         *
611
         * @since 1.xx.x
612
         *
613
         * @return bool True when registration succeeded and state was saved.
614
         */
615
        private function handle_site_registration(): bool {
616
                $license_key = self::get_license_key();
×
617
                if ( empty( $license_key ) ) {
×
618
                        set_transient(
×
619
                                $this->get_notice_transient_key(),
×
620
                                [
×
621
                                        'type'    => 'error',
×
622
                                        'message' => __( 'No license key found. Please activate a license before registering your site.', 'accessibility-checker' ),
×
623
                                ],
×
624
                                self::NOTICE_TRANSIENT_TTL
×
625
                        );
×
626
                        return false;
×
627
                }
628
                $site_url  = site_url();
×
629
                $site_name = get_bloginfo( 'name' );
×
630

631
                $response_data = self::register_site( $license_key, $site_url, $site_name, true, true );
×
632
                if ( empty( $response_data['success'] ) ) {
×
633
                        $error_msg = ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while registering the site.', 'accessibility-checker' );
×
634
                        set_transient(
×
635
                                $this->get_notice_transient_key(),
×
636
                                [
×
637
                                        'type'    => 'error',
×
638
                                        'message' => $error_msg,
×
639
                                ],
×
640
                                self::NOTICE_TRANSIENT_TTL
×
641
                        );
×
642
                        return false;
×
643
                }
644
                if ( isset( $response_data['data'] ) ) {
×
645
                        $data = $response_data['data'];
×
646
                        if ( ! empty( $data['jwt_public_key'] ) ) {
×
647
                                update_option( 'edac_jwt_public_key', $data['jwt_public_key'] );
×
648
                        }
649
                        if ( ! empty( $data['site_id'] ) ) {
×
650
                                update_option( 'edac_site_id', $data['site_id'] );
×
651
                        }
652
                        if ( ! empty( $data['collection_interval_days'] ) ) {
×
653
                                update_option( 'edac_collection_interval_days', $data['collection_interval_days'] );
×
654
                        }
655
                        if ( ! empty( $data['next_collection'] ) ) {
×
656
                                update_option( 'edac_next_collection', $data['next_collection'] );
×
657
                        }
658
                        set_transient(
×
659
                                $this->get_notice_transient_key(),
×
660
                                [
×
661
                                        'type'    => 'success',
×
662
                                        'message' => __( 'Site registered successfully. Your site is now configured to use additional accessibility services.', 'accessibility-checker' ),
×
663
                                ],
×
664
                                self::NOTICE_TRANSIENT_TTL
×
665
                        );
×
666
                        return true;
×
667
                } else {
668
                        set_transient(
×
669
                                $this->get_notice_transient_key(),
×
670
                                [
×
671
                                        'type'    => 'warning',
×
672
                                        'message' => __( 'Site registration completed, but the response data was not in the expected format. Some features may not work correctly.', 'accessibility-checker' ),
×
673
                                ],
×
674
                                self::NOTICE_TRANSIENT_TTL
×
675
                        );
×
676
                        return false;
×
677
                }
678
        }
679

680
        /**
681
         * Refresh enrollment after Pro activation when the site is already connected.
682
         *
683
         * This keeps backend enrollment context aligned on free->pro upgrades without
684
         * requiring users to manually disconnect/reconnect reports.
685
         *
686
         * @param string      $license      Activated license key.
687
         * @param string      $url          Site URL from activation hook.
688
         * @param object|null $license_data Activation response payload.
689
         * @return void
690
         */
691
        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.
692
                $site_id = (string) get_option( 'edac_site_id', '' );
2✔
693
                if ( '' === $site_id ) {
2✔
694
                        return;
×
695
                }
696

697
                $license_key = self::get_license_key();
2✔
698
                if ( '' === $license_key ) {
2✔
699
                        return;
×
700
                }
701

702
                $response_data = self::register_site( $license_key, site_url(), get_bloginfo( 'name' ), true, true );
2✔
703
                if ( empty( $response_data['success'] ) || empty( $response_data['data'] ) ) {
2✔
704
                        return;
×
705
                }
706

707
                $data = $response_data['data'];
2✔
708
                if ( ! empty( $data['jwt_public_key'] ) ) {
2✔
709
                        update_option( 'edac_jwt_public_key', $data['jwt_public_key'] );
2✔
710
                }
711
                if ( ! empty( $data['site_id'] ) ) {
2✔
712
                        update_option( 'edac_site_id', $data['site_id'] );
2✔
713
                }
714
                if ( ! empty( $data['collection_interval_days'] ) ) {
2✔
715
                        update_option( 'edac_collection_interval_days', $data['collection_interval_days'] );
2✔
716
                }
717
                if ( ! empty( $data['next_collection'] ) ) {
2✔
718
                        update_option( 'edac_next_collection', $data['next_collection'] );
2✔
719
                }
720
        }
721

722
        /**
723
         * Handle the site unregistration process including UI feedback.
724
         *
725
         * @since 1.xx.x
726
         *
727
         * @param string      $license      Optional license key passed from deactivation hooks.
728
         * @param string      $url          Optional site URL from deactivation hooks.
729
         * @param object|null $license_data Optional license payload from deactivation hooks.
730
         *
731
         * @return void
732
         */
733
        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.
734
                $preserve_license = self::should_preserve_license_on_unregistration();
8✔
735
                $site_id          = get_option( 'edac_site_id' );
8✔
736
                $license_key      = '' !== (string) $license ? (string) $license : self::get_license_key();
8✔
737
                if ( empty( $site_id ) || empty( $license_key ) ) {
8✔
738
                        // Clear local report connection state even when required data is missing.
739
                        self::clear_report_connection_state();
4✔
740
                        if ( ! $preserve_license ) {
4✔
741
                                // Free disconnect keeps the historical behavior of clearing the key.
742
                                self::clear_free_disconnect_license_state();
2✔
743
                        }
744
                        set_transient(
4✔
745
                                $this->get_notice_transient_key(),
4✔
746
                                [
4✔
747
                                        'type'    => 'error',
4✔
748
                                        'message' => __( 'Unable to unregister site. Required registration data is missing.', 'accessibility-checker' ),
4✔
749
                                ],
4✔
750
                                self::NOTICE_TRANSIENT_TTL
4✔
751
                        );
4✔
752
                        return;
4✔
753
                }
754
                $response_data = self::unregister_site( $site_id, get_site_url(), $license_key );
4✔
755

756
                // Always clear local report state so reports are disabled immediately.
757
                self::clear_report_connection_state();
4✔
758
                if ( ! $preserve_license ) {
4✔
759
                        // Free disconnect keeps the historical behavior of clearing the key,
760
                        // even when the API response is an error.
761
                        self::clear_free_disconnect_license_state();
×
762
                }
763

764
                if ( empty( $response_data['success'] ) ) {
4✔
765
                        $error_msg = ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while unregistering the site.', 'accessibility-checker' );
2✔
766
                        set_transient(
2✔
767
                                $this->get_notice_transient_key(),
2✔
768
                                [
2✔
769
                                        'type'    => 'error',
2✔
770
                                        'message' => $error_msg,
2✔
771
                                ],
2✔
772
                                self::NOTICE_TRANSIENT_TTL
2✔
773
                        );
2✔
774
                        return;
2✔
775
                }
776
                set_transient(
2✔
777
                        $this->get_notice_transient_key(),
2✔
778
                        [
2✔
779
                                'type'    => 'success',
2✔
780
                                'message' => __( 'Site unregistered successfully. Your site will no longer receive email reports.', 'accessibility-checker' ),
2✔
781
                        ],
2✔
782
                        self::NOTICE_TRANSIENT_TTL
2✔
783
                );
2✔
784
        }
785

786
        /**
787
         * Register a site with the MyDot API.
788
         *
789
         * @since 1.xx.x
790
         *
791
         * @param string $license_key     The license key to register the site with.
792
         * @param string $site_url        The URL of the site to register.
793
         * @param string $site_name       The name of the site to register.
794
         * @param bool   $weekly_reports  Whether to enable weekly reports.
795
         * @param bool   $monthly_reports Whether to enable monthly reports.
796
         *
797
         * @return array The response data from the API.
798
         */
799
        public static function register_site( $license_key, $site_url, $site_name, $weekly_reports = true, $monthly_reports = true ) {
800
                if ( empty( $license_key ) ) {
2✔
801
                        return [
×
802
                                'success' => false,
×
803
                                'message' => __( 'No license key provided.', 'accessibility-checker' ),
×
804
                        ];
×
805
                }
806
                $request_data = [
2✔
807
                        'site_url'        => $site_url,
2✔
808
                        'site_name'       => $site_name,
2✔
809
                        'license_key'     => $license_key,
2✔
810
                        'weekly_reports'  => $weekly_reports,
2✔
811
                        'monthly_reports' => $monthly_reports,
2✔
812
                ];
2✔
813

814
                if ( self::should_use_filtered_product_id_for_enrollment() ) {
2✔
815
                        $request_data['product_id'] = self::get_product_id();
×
816
                }
817
                $response = wp_remote_post(
2✔
818
                        self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/register-site',
2✔
819
                        [
2✔
820
                                'headers'     => [ 'Content-Type' => 'application/json' ],
2✔
821
                                'body'        => wp_json_encode( $request_data ),
2✔
822
                                'method'      => 'POST',
2✔
823
                                'data_format' => 'body',
2✔
824
                                'timeout'     => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
2✔
825
                                'sslverify'   => self::verify_ssl(),
2✔
826
                        ]
2✔
827
                );
2✔
828
                if ( is_wp_error( $response ) ) {
2✔
829
                        return [
×
830
                                'success' => false,
×
831
                                'message' => $response->get_error_message(),
×
832
                        ];
×
833
                }
834
                $response_code = wp_remote_retrieve_response_code( $response );
2✔
835
                $response_body = wp_remote_retrieve_body( $response );
2✔
836
                $response_data = json_decode( $response_body, true );
2✔
837
                if ( 200 !== $response_code || empty( $response_body ) ) {
2✔
838
                        return [
×
839
                                'success' => false,
×
840
                                'message' => ( ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while registering the site.', 'accessibility-checker' ) ),
×
841
                        ];
×
842
                }
843
                return $response_data;
2✔
844
        }
845

846
        /**
847
         * Unregister a site from the MyDot API.
848
         *
849
         * @since 1.xx.x
850
         *
851
         * @param string $site_id     The site ID for the registered site.
852
         * @param string $site_url    The URL of the site to unregister.
853
         * @param string $license_key The license key associated with the site.
854
         *
855
         * @return array The response data from the API.
856
         */
857
        public static function unregister_site( $site_id, $site_url, $license_key ) {
858
                if ( empty( $site_id ) || empty( $site_url ) || empty( $license_key ) ) {
6✔
859
                        return [
×
860
                                'success' => false,
×
861
                                'message' => __( 'Missing required parameters for unregistration.', 'accessibility-checker' ),
×
862
                        ];
×
863
                }
864
                $request_data = [
6✔
865
                        'site_id'     => $site_id,
6✔
866
                        'site_url'    => $site_url,
6✔
867
                        'license_key' => $license_key,
6✔
868
                ];
6✔
869

870
                if ( self::should_use_filtered_product_id_for_enrollment() ) {
6✔
871
                        $request_data['product_id'] = self::get_product_id();
6✔
872
                }
873
                $response = wp_remote_post(
6✔
874
                        self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/unregister-site',
6✔
875
                        [
6✔
876
                                'headers'     => [
6✔
877
                                        'Content-Type' => 'application/json',
6✔
878
                                ],
6✔
879
                                'body'        => wp_json_encode( $request_data ),
6✔
880
                                'method'      => 'POST',
6✔
881
                                'data_format' => 'body',
6✔
882
                                'timeout'     => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
6✔
883
                                'sslverify'   => self::verify_ssl(),
6✔
884
                        ]
6✔
885
                );
6✔
886
                if ( is_wp_error( $response ) ) {
6✔
887
                        return [
×
888
                                'success' => false,
×
889
                                'message' => $response->get_error_message(),
×
890
                        ];
×
891
                }
892
                $response_code = wp_remote_retrieve_response_code( $response );
6✔
893
                $response_body = wp_remote_retrieve_body( $response );
6✔
894
                $response_data = json_decode( $response_body, true );
6✔
895
                if ( 200 !== $response_code || empty( $response_body ) ) {
6✔
896
                        return [
4✔
897
                                'success' => false,
4✔
898
                                'message' => ( ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while unregistering the site.', 'accessibility-checker' ) ),
4✔
899
                        ];
4✔
900
                }
901
                return $response_data;
2✔
902
        }
903

904
        /**
905
         * Get the expected issuer for JWT validation (RFC 8725).
906
         *
907
         * @since 1.xx.x
908
         *
909
         * @return string The issuer URL/identifier.
910
         */
911
        public static function get_jwt_issuer() {
912
                // strip the protocol for issuer comparison.
913
                return apply_filters( 'edac_jwt_issuer', preg_replace( '#^https?://#', '', self::get_api_endpoint() ) );
×
914
        }
915

916
        /**
917
         * Get the expected audience for JWT validation (RFC 8725).
918
         *
919
         * @since 1.xx.x
920
         *
921
         * @return string The audience identifier (site URL or API endpoint identifier).
922
         */
923
        public static function get_jwt_audience() {
924
                // strip the protocol for audience comparison.
925
                return apply_filters( 'edac_jwt_audience', preg_replace( '#^https?://#', '', home_url() ) );
×
926
        }
927

928
        /**
929
         * Validate a JWT token using the stored public key (RFC 8725 compliant).
930
         *
931
         * Validates:
932
         * - Token structure (3 parts separated by dots)
933
         * - Header algorithm (RS256)
934
         * - Signature using stored public key
935
         * - Token expiration (exp claim)
936
         * - Issuer (iss claim) per RFC 8725 to prevent token substitution attacks
937
         * - Audience (aud claim) per RFC 8725 to ensure token is for this recipient
938
         * - Not Before (nbf claim) if present
939
         *
940
         * @since 1.xx.x
941
         *
942
         * @param string $token The JWT token to validate.
943
         * @return bool True if the token is valid, false otherwise.
944
         */
945
        public static function validate_jwt_token( $token ) {
946
                if ( empty( $token ) ) {
2✔
947
                        return false;
×
948
                }
949
                $public_key = get_option( 'edac_jwt_public_key' );
2✔
950
                if ( empty( $public_key ) ) {
2✔
951
                        return false;
2✔
952
                }
953
                $parts = explode( '.', $token );
×
954
                if ( count( $parts ) !== 3 ) {
×
955
                        return false;
×
956
                }
957
                list( $header_b64, $payload_b64, $signature_b64 ) = $parts;
×
958

959
                $header_json  = self::base64url_decode_strict( $header_b64 );
×
960
                $payload_json = self::base64url_decode_strict( $payload_b64 );
×
961
                if ( false === $header_json || false === $payload_json ) {
×
962
                        return false;
×
963
                }
964

965
                $header  = json_decode( $header_json, true );
×
966
                $payload = json_decode( $payload_json, true );
×
967
                if ( ! $header || ! $payload ) {
×
968
                        return false;
×
969
                }
970

971
                // Require that aud, iss and exp all exist.
972
                if ( ! isset( $payload['aud'], $payload['iss'], $payload['exp'] ) ) {
×
973
                        return false;
×
974
                }
975
                // The exp should be numeric and an int.
976
                if ( ! is_numeric( $payload['exp'] ) ) {
×
977
                        return false;
×
978
                }
979
                $exp = (int) $payload['exp'];
×
980

981
                $message           = $header_b64 . '.' . $payload_b64;
×
982
                $signature_decoded = self::base64url_decode_strict( $signature_b64 );
×
983
                if ( false === $signature_decoded ) {
×
984
                        return false;
×
985
                }
986

987
                $algo = $header['alg'] ?? 'RS256';
×
988
                if ( 'RS256' !== $algo ) {
×
989
                        return false;
×
990
                }
991

992
                $public_key_resource = openssl_pkey_get_public( $public_key );
×
993
                if ( ! $public_key_resource ) {
×
994
                        return false;
×
995
                }
996

997
                $verify_result = openssl_verify( $message, $signature_decoded, $public_key_resource, OPENSSL_ALGO_SHA256 );
×
998
                if ( 1 !== $verify_result ) {
×
999
                        return false;
×
1000
                }
1001

1002
                $current_time = time();
×
1003
                // Validate expiration (exp claim) - required by RFC 8725.
1004
                if ( $exp < $current_time ) {
×
1005
                        return false;
×
1006
                }
1007

1008
                // RFC 8725: Validate issuer claim to prevent token substitution attacks.
1009
                $expected_iss = self::get_jwt_issuer();
×
1010
                if ( $payload['iss'] !== $expected_iss ) {
×
1011
                        return false;
×
1012
                }
1013

1014
                // RFC 8725: Validate audience claim - if issuer issues JWTs for multiple recipients,
1015
                // the JWT must contain an "aud" claim and must be validated.
1016
                $expected_aud = self::get_jwt_audience();
×
1017
                $token_aud    = $payload['aud'];
×
1018
                // aud can be a string or an array of strings per RFC 7519.
1019
                $aud_list = is_array( $token_aud ) ? $token_aud : [ $token_aud ];
×
1020
                if ( ! in_array( $expected_aud, $aud_list, true ) ) {
×
1021
                        return false;
×
1022
                }
1023

1024
                // RFC 8725: Validate not-before claim (nbf) if present.
1025
                if ( isset( $payload['nbf'] ) ) {
×
1026
                        if ( ! is_numeric( $payload['nbf'] ) ) {
×
1027
                                return false;
×
1028
                        }
1029
                        if ( (int) $payload['nbf'] > $current_time ) {
×
1030
                                return false;
×
1031
                        }
1032
                }
1033

1034
                return true;
×
1035
        }
1036

1037
        /**
1038
         * Validate JWT token with reactive fallback.
1039
         *
1040
         * If validation fails, attempt to refresh the public key from the issuer and retry.
1041
         * This handles cases where the issuer rotated keys but the site's cron hasn't run yet.
1042
         *
1043
         * @since 1.xx.x
1044
         *
1045
         * @param string $token The JWT token to validate.
1046
         * @return bool True if valid (either on first try or after key refresh), false otherwise.
1047
         */
1048
        public static function validate_jwt_token_with_fallback( $token ) {
1049
                // Try initial validation.
1050
                if ( self::validate_jwt_token( $token ) ) {
2✔
1051
                        return true;
×
1052
                }
1053

1054
                // Validation failed. Try to refresh the public key from the issuer.
1055
                if ( self::refresh_public_key_from_issuer() ) {
2✔
1056
                        // Key was refreshed, retry validation with the new key.
1057
                        return self::validate_jwt_token( $token );
×
1058
                }
1059

1060
                // Still invalid after refresh attempt.
1061
                return false;
2✔
1062
        }
1063

1064
        /**
1065
         * Permission helper for validating JWT token in REST request with fallback (Option 2 + 3).
1066
         *
1067
         * @since 1.xx.x
1068
         *
1069
         * @param \WP_REST_Request $request The REST request object.
1070
         * @return bool True if valid JWT token is present, false otherwise.
1071
         */
1072
        public static function validate_jwt_token_in_request_with_fallback( $request ) {
1073
                if ( ! $request instanceof \WP_REST_Request ) {
10✔
1074
                        return false;
2✔
1075
                }
1076

1077
                // Extract the JWT token from the Authorization header.
1078
                $auth_header = $request->get_header( 'Authorization' );
8✔
1079
                $parts       = null !== $auth_header ? explode( ' ', $auth_header ) : [];
8✔
1080
                if ( ! empty( $auth_header ) ) {
8✔
1081
                        if ( count( $parts ) === 2 && 'Bearer' === $parts[0] ) {
6✔
1082
                                // Check if the site is registered currently before attempting validation.
1083
                                $site_id = (string) get_option( 'edac_site_id', '' );
6✔
1084
                                if ( '' === $site_id ) {
6✔
1085
                                        return false;
4✔
1086
                                }
1087

1088
                                // Use the fallback validator which will refresh key if needed.
1089
                                return self::validate_jwt_token_with_fallback( $parts[1] );
2✔
1090
                        }
1091
                }
1092

1093
                // No valid Bearer token found.
1094
                return false;
2✔
1095
        }
1096

1097
        /**
1098
         * Check if stored JWT public key needs to be updated from a fresh registration.
1099
         *
1100
         * Called after successful site registration to verify the stored key is current.
1101
         * If the stored key doesn't match what the issuer sent, it's already been rotated.
1102
         *
1103
         * Uses a simple GET request since public keys don't require authentication.
1104
         *
1105
         * @since 1.xx.x
1106
         *
1107
         * @return bool True if public key was updated or is current, false on error.
1108
         */
1109
        public static function verify_and_update_public_key() {
1110
                $stored_key = get_option( 'edac_jwt_public_key' );
4✔
1111

1112
                if ( empty( $stored_key ) ) {
4✔
1113
                        return false;
4✔
1114
                }
1115

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

1119
                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
×
1120
                        return false;
×
1121
                }
1122

1123
                $data = json_decode( wp_remote_retrieve_body( $response ), true );
×
1124

1125
                // If issuer returned a new public key, store it immediately.
1126
                if ( ! empty( $data['jwt_public_key'] ) && $data['jwt_public_key'] !== $stored_key ) {
×
1127
                        update_option( 'edac_jwt_public_key', $data['jwt_public_key'] );
×
1128
                        return true; // Key was updated.
×
1129
                }
1130

1131
                return true; // Key is current.
×
1132
        }
1133

1134
        /**
1135
         * Attempt to update the public key from API on failed JWT validation.
1136
         *
1137
         * If JWT validation fails, this optional step re-requests the public key
1138
         * from the issuer. Useful if the issuer rotated keys but the site hasn't
1139
         * refreshed them yet.
1140
         *
1141
         * This is called AFTER a JWT fails validation, so only use as a fallback
1142
         * to avoid constant API calls.
1143
         *
1144
         * Uses a simple GET request since public keys don't require authentication.
1145
         *
1146
         * @since 1.xx.x
1147
         *
1148
         * @return bool True if key was retrieved and stored, false otherwise.
1149
         */
1150
        public static function refresh_public_key_from_issuer() {
1151
                $response = self::safe_remote_get( self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/get-public-key' );
2✔
1152

1153
                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
2✔
1154
                        return false;
×
1155
                }
1156

1157
                $data = json_decode( wp_remote_retrieve_body( $response ), true );
2✔
1158
                if ( ! empty( $data['public_key'] ) ) {
2✔
1159
                        update_option( 'edac_jwt_public_key', $data['public_key'] );
×
1160
                        return true;
×
1161
                }
1162

1163
                return false;
2✔
1164
        }
1165

1166
        /**
1167
         * Perform a safe GET request compatible with VIP and non-VIP environments.
1168
         *
1169
         * Uses vip_safe_wp_remote_get() if available, otherwise falls back to wp_remote_get().
1170
         *
1171
         * @param string $url  The URL to request.
1172
         * @param array  $args Optional request args.
1173
         * @return array|\WP_Error Response array or WP_Error on failure.
1174
         */
1175
        private static function safe_remote_get( string $url, array $args = [] ) {
1176
                $defaults = [
2✔
1177
                        'timeout'   => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments.
2✔
1178
                        'sslverify' => self::verify_ssl(),
2✔
1179
                ];
2✔
1180
                $args     = wp_parse_args( $args, $defaults );
2✔
1181

1182
                if ( function_exists( 'vip_safe_wp_remote_get' ) ) {
2✔
1183
                        $timeout     = isset( $args['timeout'] ) ? max( 1, min( 5, (int) $args['timeout'] ) ) : 5;
×
1184
                        $retry_count = isset( $args['retry'] ) ? (int) $args['retry'] : 10;
×
1185
                        return vip_safe_wp_remote_get( $url, '', 3, $timeout, $retry_count, $args );
×
1186
                }
1187

1188
                return wp_remote_get( $url, $args ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- fallback for non-VIP environments.
2✔
1189
        }
1190

1191
        /**
1192
         * Display transient-based admin notices for the current user.
1193
         */
1194
        public function display_admin_notices() {
1195
                $key    = $this->get_notice_transient_key();
×
1196
                $notice = get_transient( $key );
×
1197

1198
                if ( empty( $notice['type'] ) || empty( $notice['message'] ) ) {
×
1199
                        return;
×
1200
                }
1201
                delete_transient( $key );
×
1202

1203
                $allowed_types = [ 'success', 'error', 'warning', 'info' ];
×
1204
                $type          = in_array( $notice['type'], $allowed_types, true ) ? $notice['type'] : 'info';
×
1205
                $message       = is_string( $notice['message'] ) ? $notice['message'] : '';
×
1206
                if ( '' === $message ) {
×
1207
                        return;
×
1208
                }
1209

1210
                printf(
×
1211
                        '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
×
1212
                        esc_attr( $type ),
×
1213
                        esc_html( $message )
×
1214
                );
×
1215
        }
1216

1217
        /**
1218
         * Build the transient key for connector notices.
1219
         *
1220
         * @param int|null $user_id Optional user ID; defaults to current user.
1221
         *
1222
         * @return string
1223
         */
1224
        private function get_notice_transient_key( $user_id = null ) {
1225
                $user_id = null === $user_id ? get_current_user_id() : (int) $user_id;
8✔
1226

1227
                return 'edac_connector_notice_' . absint( $user_id );
8✔
1228
        }
1229

1230
        /**
1231
         * Strict Base64URL decode that returns false on invalid input.
1232
         *
1233
         * @since 1.xx.x
1234
         *
1235
         * @param string $b64url The Base64URL encoded string.
1236
         * @return string|false The decoded string, or false on failure.
1237
         */
1238
        private static function base64url_decode_strict( string $b64url ) {
1239
                $b64 = strtr( $b64url, '-_', '+/' );
×
1240
                $pad = strlen( $b64 ) % 4;
×
1241
                if ( $pad ) {
×
1242
                        $b64 .= str_repeat( '=', 4 - $pad );
×
1243
                }
1244
                return base64_decode( $b64, true );
×
1245
        }
1246

1247
        /**
1248
         * Infer license metadata from an EDD response.
1249
         *
1250
         * Determines the license type (free/pro), level (single-site/multi-site/unlimited/lifetime),
1251
         * and formats response fields for storage.
1252
         *
1253
         * Primary inference uses product ID (most reliable when free/pro have distinct IDs).
1254
         * Secondary fallback uses item_name string matching.
1255
         * Tertiary fallback uses the source context (e.g., 'free', 'pro').
1256
         *
1257
         * @param object|array $license_data EDD response payload.
1258
         * @param string       $source       Activation/check source context ('free' or 'pro').
1259
         * @return array Inferred metadata with keys: type, level, item_id, item_name, expires, license_limit, site_count, activations_left, last_response_at.
1260
         *
1261
         * @since 1.xx.x
1262
         */
1263
        public static function infer_license_metadata_from_response( $license_data, string $source ): array {
1264
                if ( ! is_object( $license_data ) && ! is_array( $license_data ) ) {
14✔
1265
                        return [
×
1266
                                'type'             => self::LICENSE_TYPE_UNKNOWN,
×
1267
                                'level'            => self::LICENSE_LEVEL_UNKNOWN,
×
1268
                                'item_id'          => 0,
×
1269
                                'item_name'        => '',
×
1270
                                'expires'          => '',
×
1271
                                'license_limit'    => '',
×
1272
                                'site_count'       => '',
×
1273
                                'activations_left' => '',
×
1274
                                'last_response_at' => time(),
×
1275
                        ];
×
1276
                }
1277

1278
                $data = is_object( $license_data ) ? get_object_vars( $license_data ) : $license_data;
14✔
1279

1280
                // Validate response has at least basic structure (item_id or item_name).
1281
                if ( empty( $data['item_id'] ) && empty( $data['item_name'] ) ) {
14✔
1282
                        // Incomplete response; use source as last resort.
1283
                        return [
×
1284
                                'type'             => in_array( $source, [ self::LICENSE_TYPE_FREE, self::LICENSE_TYPE_PRO ], true ) ? $source : self::LICENSE_TYPE_UNKNOWN,
×
1285
                                'level'            => self::LICENSE_LEVEL_UNKNOWN,
×
1286
                                'item_id'          => 0,
×
1287
                                'item_name'        => '',
×
1288
                                'expires'          => '',
×
1289
                                'license_limit'    => '',
×
1290
                                'site_count'       => '',
×
1291
                                'activations_left' => '',
×
1292
                                'last_response_at' => time(),
×
1293
                        ];
×
1294
                }
1295

1296
                $item_name = sanitize_text_field( (string) ( $data['item_name'] ?? '' ) );
14✔
1297
                $item_id   = absint( $data['item_id'] ?? 0 );
14✔
1298
                $limit_raw = $data['license_limit'] ?? '';
14✔
1299

1300
                // Primary: infer from product ID in response — most reliable when free/pro have distinct IDs.
1301
                $type           = self::LICENSE_TYPE_UNKNOWN;
14✔
1302
                $pro_product_id = (int) apply_filters( 'edac_pro_product_id', 0 );
14✔
1303
                if ( $item_id > 0 ) {
14✔
1304
                        if ( self::PRODUCT_ID === $item_id ) {
12✔
1305
                                $type = self::LICENSE_TYPE_FREE;
10✔
1306
                        } elseif ( $pro_product_id > 0 && $pro_product_id === $item_id ) {
2✔
1307
                                // Inferred as Pro because Pro's product ID filter matched.
1308
                                $type = self::LICENSE_TYPE_PRO;
2✔
1309
                        }
1310
                }
1311

1312
                // Secondary: infer from item_name string match.
1313
                if ( self::LICENSE_TYPE_UNKNOWN === $type && '' !== $item_name ) {
14✔
1314
                        $item_name_normalized = strtolower( $item_name );
2✔
1315
                        if ( false !== strpos( $item_name_normalized, 'pro' ) ) {
2✔
1316
                                $type = self::LICENSE_TYPE_PRO;
2✔
1317
                        } elseif ( false !== strpos( $item_name_normalized, 'free' ) ) {
×
1318
                                $type = self::LICENSE_TYPE_FREE;
×
1319
                        }
1320
                }
1321

1322
                // Fallback: use source context.
1323
                if ( self::LICENSE_TYPE_UNKNOWN === $type ) {
14✔
1324
                        $type = in_array( $source, [ self::LICENSE_TYPE_FREE, self::LICENSE_TYPE_PRO ], true ) ? $source : self::LICENSE_TYPE_UNKNOWN;
×
1325
                }
1326

1327
                $level = self::LICENSE_LEVEL_UNKNOWN;
14✔
1328
                if ( is_numeric( $limit_raw ) ) {
14✔
1329
                        $limit = (int) $limit_raw;
12✔
1330
                        if ( 0 === $limit ) {
12✔
1331
                                $level = self::LICENSE_LEVEL_UNLIMITED; // EDD uses 0 to mean no activation limit.
2✔
1332
                        } elseif ( 1 === $limit ) {
10✔
1333
                                $level = self::LICENSE_LEVEL_SINGLE_SITE;
6✔
1334
                        } elseif ( $limit > 1 ) {
4✔
1335
                                $level = self::LICENSE_LEVEL_MULTI_SITE;
12✔
1336
                        }
1337
                } elseif ( is_string( $limit_raw ) ) {
2✔
1338
                        $limit_normalized = strtolower( trim( $limit_raw ) );
2✔
1339
                        if ( in_array( $limit_normalized, [ self::LICENSE_LEVEL_LIFETIME, self::LICENSE_LEVEL_UNLIMITED ], true ) ) {
2✔
1340
                                $level = $limit_normalized;
2✔
1341
                        }
1342
                }
1343

1344
                return [
14✔
1345
                        'type'             => $type,
14✔
1346
                        'level'            => $level,
14✔
1347
                        'item_id'          => $item_id,
14✔
1348
                        'item_name'        => $item_name,
14✔
1349
                        'expires'          => sanitize_text_field( (string) ( $data['expires'] ?? '' ) ),
14✔
1350
                        'license_limit'    => sanitize_text_field( (string) $limit_raw ),
14✔
1351
                        'site_count'       => sanitize_text_field( (string) ( $data['site_count'] ?? '' ) ),
14✔
1352
                        'activations_left' => sanitize_text_field( (string) ( $data['activations_left'] ?? '' ) ),
14✔
1353
                        'last_response_at' => time(),
14✔
1354
                ];
14✔
1355
        }
1356

1357
        /**
1358
         * Persist inferred license metadata from an EDD response.
1359
         *
1360
         * @param object|array|null $license_data EDD response payload.
1361
         * @param string            $source       Activation/check source context.
1362
         * @return void
1363
         */
1364
        private static function store_license_metadata_from_response( $license_data, string $source ): void {
1365
                $metadata = self::infer_license_metadata_from_response( $license_data, $source );
14✔
1366
                update_option( self::LICENSE_METADATA_OPTION, $metadata );
14✔
1367
        }
1368

1369
        /**
1370
         * Clear stored inferred license metadata.
1371
         *
1372
         * @return void
1373
         */
1374
        private static function clear_stored_license_metadata(): void {
1375
                delete_option( self::LICENSE_METADATA_OPTION );
6✔
1376
        }
1377
}
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