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

equalizedigital / accessibility-checker / 26730515205

01 Jun 2026 01:30AM UTC coverage: 59.571% (+1.0%) from 58.556%
26730515205

push

github

web-flow
Merge pull request #1728 from equalizedigital/steve/pro-778-email-reports-change-destination-for-dashboard-link

Email Reports: Update dashboard link destination to license keys page

0 of 1 new or added line in 1 file covered. (0.0%)

10 existing lines in 2 files now uncovered.

5751 of 9654 relevant lines covered (59.57%)

5.3 hits per line

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

78.89
/includes/classes/class-rest-api.php
1
<?php
2
/**
3
 * Class file for REST api
4
 *
5
 * @package Accessibility_Checker
6
 */
7

8
namespace EDAC\Inc;
9

10
use EDAC\Admin\Insert_Rule_Data;
11
use EDAC\Admin\Scans_Stats;
12
use EDAC\Admin\Settings;
13
use EDAC\Admin\Purge_Post_Data;
14
use EqualizeDigital\AccessibilityChecker\MyDot\Connector;
15

16
if ( ! defined( 'ABSPATH' ) ) {
17
        exit;
18
}
19

20
/**
21
 * Class that initializes and handles the REST api
22
 */
23
class REST_Api {
24

25

26
        /**
27
         * Constructor
28
         */
29
        public function __construct() {
30
        }
6✔
31

32

33
        /**
34
         * Add the class the hooks.
35
         */
36
        public function init_hooks() {
37
                add_action( 'init', [ $this, 'init_rest_routes' ] );
×
38
                add_filter( 'edac_filter_js_violation_html', [ $this, 'filter_js_validation_html' ], 10, 3 );
×
39
        }
40

41

42
        /**
43
         * Add the rest routes.
44
         *
45
         * @return void
46
         */
47
        public function init_rest_routes() {
48

49
                $ns      = 'accessibility-checker/';
38✔
50
                $version = 'v1';
38✔
51

52
                add_action(
38✔
53
                        'rest_api_init',
38✔
54
                        function () use ( $ns, $version ) {
38✔
55
                                register_rest_route(
38✔
56
                                        $ns . $version,
38✔
57
                                        '/test',
38✔
58
                                        [
38✔
59
                                                'methods'             => [ 'GET', 'POST' ],
38✔
60
                                                'callback'            => function () {
38✔
61
                                                        $messages          = [];
×
62
                                                        $messages['time']  = time();
×
63
                                                        $messages['perms'] = current_user_can( 'edit_posts' );
×
64

65
                                                        return new \WP_REST_Response( [ 'messages' => $messages ], 200 );
×
66
                                                },
38✔
67
                                                'permission_callback' => function () {
38✔
68
                                                        return current_user_can( 'edit_posts' );
×
69
                                                },
38✔
70
                                        ]
38✔
71
                                );
38✔
72
                        }
38✔
73
                );
38✔
74

75
                add_action(
38✔
76
                        'rest_api_init',
38✔
77
                        function () use ( $ns, $version ) {
38✔
78
                                register_rest_route(
38✔
79
                                        $ns . $version,
38✔
80
                                        '/post-scan-results/(?P<id>\d+)',
38✔
81
                                        [
38✔
82
                                                'methods'             => 'POST',
38✔
83
                                                'callback'            => [ $this, 'set_post_scan_results' ],
38✔
84
                                                'args'                => [
38✔
85
                                                        'id' => [
38✔
86
                                                                'required'          => true,
38✔
87
                                                                'validate_callback' => function ( $param ) {
38✔
88
                                                                        return is_numeric( $param );
4✔
89
                                                                },
38✔
90
                                                                'sanitize_callback' => 'absint',
38✔
91
                                                        ],
38✔
92
                                                ],
38✔
93
                                                'permission_callback' => function ( $request ) {
38✔
94
                                                        $post_id = (int) $request['id'];
4✔
95
                                                        return current_user_can( 'edit_post', $post_id ); // able to edit the post.
4✔
96
                                                },
38✔
97
                                        ]
38✔
98
                                );
38✔
99
                        }
38✔
100
                );
38✔
101

102
                add_action(
38✔
103
                        'rest_api_init',
38✔
104
                        function () use ( $ns, $version ) {
38✔
105
                                register_rest_route(
38✔
106
                                        $ns . $version,
38✔
107
                                        '/scans-stats',
38✔
108
                                        [
38✔
109
                                                'methods'             => 'GET',
38✔
110
                                                'callback'            => [ $this, 'get_scans_stats' ],
38✔
111
                                                'permission_callback' => [ $this, 'can_get_scans_stats' ],
38✔
112
                                        ]
38✔
113
                                );
38✔
114
                        }
38✔
115
                );
38✔
116

117
                add_action(
38✔
118
                        'rest_api_init',
38✔
119
                        function () use ( $ns, $version ) {
38✔
120
                                register_rest_route(
38✔
121
                                        $ns . $version,
38✔
122
                                        '/clear-cached-scans-stats',
38✔
123
                                        [
38✔
124
                                                'methods'             => 'POST',
38✔
125
                                                'callback'            => [ $this, 'clear_cached_scans_stats' ],
38✔
126
                                                'permission_callback' => function () {
38✔
127
                                                        return current_user_can( 'publish_posts' );
2✔
128
                                                },
38✔
129
                                        ]
38✔
130
                                );
38✔
131
                        }
38✔
132
                );
38✔
133

134
                add_action(
38✔
135
                        'rest_api_init',
38✔
136
                        function () use ( $ns, $version ) {
38✔
137
                                register_rest_route(
38✔
138
                                        $ns . $version,
38✔
139
                                        '/scans-stats-by-post-type/(?P<slug>[a-zA-Z0-9_-]+)',
38✔
140
                                        [
38✔
141
                                                'methods'             => 'GET',
38✔
142
                                                'callback'            => [ $this, 'get_scans_stats_by_post_type' ],
38✔
143
                                                'permission_callback' => function () {
38✔
144
                                                        return current_user_can( 'edit_posts' );
2✔
145
                                                },
38✔
146
                                        ]
38✔
147
                                );
38✔
148
                        }
38✔
149
                );
38✔
150

151
                add_action(
38✔
152
                        'rest_api_init',
38✔
153
                        function () use ( $ns, $version ) {
38✔
154
                                register_rest_route(
38✔
155
                                        $ns . $version,
38✔
156
                                        '/scans-stats-by-post-types',
38✔
157
                                        [
38✔
158
                                                'methods'             => 'GET',
38✔
159
                                                'callback'            => [ $this, 'get_scans_stats_by_post_types' ],
38✔
160
                                                'permission_callback' => function () {
38✔
161
                                                        return current_user_can( 'edit_posts' );
2✔
162
                                                },
38✔
163
                                        ]
38✔
164
                                );
38✔
165
                        }
38✔
166
                );
38✔
167

168
                add_action(
38✔
169
                        'rest_api_init',
38✔
170
                        function () use ( $ns, $version ) {
38✔
171
                                register_rest_route(
38✔
172
                                        $ns . $version,
38✔
173
                                        '/clear-issues/(?P<id>\d+)',
38✔
174
                                        [
38✔
175
                                                'methods'             => 'POST',
38✔
176
                                                'callback'            => [ $this, 'clear_issues_for_post' ],
38✔
177
                                                'args'                => [
38✔
178
                                                        'id' => [
38✔
179
                                                                'required'          => true,
38✔
180
                                                                'validate_callback' => function ( $param ) {
38✔
181
                                                                        return is_numeric( $param );
4✔
182
                                                                },
38✔
183
                                                                'sanitize_callback' => 'absint',
38✔
184
                                                        ],
38✔
185
                                                ],
38✔
186
                                                'permission_callback' => function ( $request ) {
38✔
187
                                                        $post_id = (int) $request['id'];
4✔
188
                                                        return current_user_can( 'edit_post', $post_id ); // able to edit the post.
4✔
189
                                                },
38✔
190
                                        ]
38✔
191
                                );
38✔
192
                        }
38✔
193
                );
38✔
194

195
                // Exposes the scan summary data.
196
                add_action(
38✔
197
                        'rest_api_init',
38✔
198
                        function () use ( $ns, $version ) {
38✔
199
                                register_rest_route(
38✔
200
                                        $ns . $version,
38✔
201
                                        '/site-summary',
38✔
202
                                        [
38✔
203
                                                'methods'             => 'GET',
38✔
204
                                                'callback'            => [ $this, 'get_site_summary' ],
38✔
205
                                                'permission_callback' => function () {
38✔
206
                                                        return current_user_can( 'edit_posts' );
×
207
                                                },
38✔
208
                                        ]
38✔
209
                                );
38✔
210
                        }
38✔
211
                );
38✔
212

213
                // Sidebar data endpoint - returns all metabox data in one call.
214
                add_action(
38✔
215
                        'rest_api_init',
38✔
216
                        function () use ( $ns, $version ) {
38✔
217
                                register_rest_route(
38✔
218
                                        $ns . $version,
38✔
219
                                        '/sidebar-data/(?P<id>\d+)',
38✔
220
                                        [
38✔
221
                                                'methods'             => 'GET',
38✔
222
                                                'callback'            => [ $this, 'get_sidebar_data' ],
38✔
223
                                                'args'                => [
38✔
224
                                                        'id' => [
38✔
225
                                                                'required'          => true,
38✔
226
                                                                'validate_callback' => function ( $param ) {
38✔
227
                                                                        return is_numeric( $param );
8✔
228
                                                                },
38✔
229
                                                                'sanitize_callback' => 'absint',
38✔
230
                                                        ],
38✔
231
                                                ],
38✔
232
                                                'permission_callback' => function ( $request ) {
38✔
233
                                                        $post_id = (int) $request['id'];
8✔
234
                                                        return current_user_can( 'edit_post', $post_id );
8✔
235
                                                },
38✔
236
                                        ]
38✔
237
                                );
38✔
238
                        }
38✔
239
                );
38✔
240

241
                // Simplified summary endpoint - saves the simplified summary text.
242
                add_action(
38✔
243
                        'rest_api_init',
38✔
244
                        function () use ( $ns, $version ) {
38✔
245
                                register_rest_route(
38✔
246
                                        $ns . $version,
38✔
247
                                        '/simplified-summary/(?P<id>\d+)',
38✔
248
                                        [
38✔
249
                                                'methods'             => 'POST',
38✔
250
                                                'callback'            => [ $this, 'save_simplified_summary' ],
38✔
251
                                                'args'                => [
38✔
252
                                                        'id'      => [
38✔
253
                                                                'required'          => true,
38✔
254
                                                                'validate_callback' => function ( $param ) {
38✔
255
                                                                        return is_numeric( $param );
×
256
                                                                },
38✔
257
                                                                'sanitize_callback' => 'absint',
38✔
258
                                                        ],
38✔
259
                                                        'summary' => [
38✔
260
                                                                'required'          => true,
38✔
261
                                                                'sanitize_callback' => 'sanitize_textarea_field',
38✔
262
                                                                'validate_callback' => function ( $param ) {
38✔
263
                                                                        return is_string( $param );
×
264
                                                                },
38✔
265
                                                        ],
38✔
266
                                                ],
38✔
267
                                                'permission_callback' => function ( $request ) {
38✔
268
                                                        $post_id = (int) $request['id'];
×
269
                                                        return current_user_can( 'edit_post', $post_id );
×
270
                                                },
38✔
271
                                        ]
38✔
272
                                );
38✔
273
                        }
38✔
274
                );
38✔
275

276
                // Dismiss/restore issue endpoint.
277
                add_action(
38✔
278
                        'rest_api_init',
38✔
279
                        function () use ( $ns, $version ) {
38✔
280
                                register_rest_route(
38✔
281
                                        $ns . $version,
38✔
282
                                        '/dismiss-issue/(?P<issue_id>\d+)',
38✔
283
                                        [
38✔
284
                                                'methods'             => 'POST',
38✔
285
                                                'callback'            => [ $this, 'dismiss_issue' ],
38✔
286
                                                'args'                => [
38✔
287
                                                        'issue_id'      => [
38✔
288
                                                                'required'          => true,
38✔
289
                                                                'validate_callback' => function ( $param ) {
38✔
290
                                                                        return is_numeric( $param );
10✔
291
                                                                },
38✔
292
                                                                'sanitize_callback' => 'absint',
38✔
293
                                                        ],
38✔
294
                                                        'action'        => [
38✔
295
                                                                'required'          => true,
38✔
296
                                                                'validate_callback' => function ( $param ) {
38✔
297
                                                                        return in_array( $param, [ 'enable', 'disable', 'dismiss', 'undismiss', 'ignore', 'unignore' ], true );
10✔
298
                                                                },
38✔
299
                                                                'sanitize_callback' => 'sanitize_text_field',
38✔
300
                                                        ],
38✔
301
                                                        'reason'        => [
38✔
302
                                                                'required'          => false,
38✔
303
                                                                'sanitize_callback' => 'sanitize_text_field',
38✔
304
                                                        ],
38✔
305
                                                        'comment'       => [
38✔
306
                                                                'required'          => false,
38✔
307
                                                                'sanitize_callback' => function ( $param ) {
38✔
308
                                                                        // Allow basic tags, then store as HTML entities.
309
                                                                        $allowed_html = [
2✔
310
                                                                                'strong' => [],
2✔
311
                                                                                'b'      => [],
2✔
312
                                                                                'em'     => [],
2✔
313
                                                                                'i'      => [],
2✔
314
                                                                                'a'      => [
2✔
315
                                                                                        'href'   => true,
2✔
316
                                                                                        'target' => true,
2✔
317
                                                                                        'rel'    => true,
2✔
318
                                                                                ],
2✔
319
                                                                        ];
2✔
320
                                                                        return esc_html( wp_kses( $param, $allowed_html ) );
2✔
321
                                                                },
38✔
322
                                                        ],
38✔
323
                                                        'ignore_global' => [
38✔
324
                                                                'required'          => false,
38✔
325
                                                                'default'           => 0,
38✔
326
                                                                'sanitize_callback' => 'absint',
38✔
327
                                                        ],
38✔
328
                                                        'largeBatch'    => [
38✔
329
                                                                'required'          => false,
38✔
330
                                                                'default'           => false,
38✔
331
                                                                'sanitize_callback' => function ( $param ) {
38✔
332
                                                                        return filter_var( $param, FILTER_VALIDATE_BOOLEAN );
10✔
333
                                                                },
38✔
334
                                                        ],
38✔
335
                                                ],
38✔
336
                                                'permission_callback' => function ( $request ) {
38✔
337
                                                        global $wpdb;
10✔
338
                                                        $issue_id = isset( $request['issue_id'] ) ? (int) $request['issue_id'] : 0;
10✔
339
                                                        if ( $issue_id <= 0 ) {
10✔
340
                                                                return false;
×
341
                                                        }
342

343
                                                        $table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' );
10✔
344
                                                        if ( ! $table_name ) {
10✔
345
                                                                return false;
×
346
                                                        }
347

348
                                                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Permission check requires direct lookup.
349
                                                        $post_id = (int) $wpdb->get_var(
10✔
350
                                                                $wpdb->prepare( 'SELECT postid FROM %i WHERE id = %d', $table_name, $issue_id )
10✔
351
                                                        );
10✔
352

353
                                                        return (bool) ( $post_id > 0 && current_user_can( 'edit_post', $post_id ) );
10✔
354
                                                },
38✔
355
                                        ]
38✔
356
                                );
38✔
357
                        }
38✔
358
                );
38✔
359
        }
360

361
        /**
362
         * Determine whether the current request may access the scans stats endpoint.
363
         *
364
         * @param \WP_REST_Request $request REST request.
365
         *
366
         * @return bool
367
         */
368
        public function can_get_scans_stats( \WP_REST_Request $request ): bool {
369
                if ( Connector::validate_jwt_token_in_request_with_fallback( $request ) ) {
2✔
370
                        // Only allow if the site is still registered (site_id present).
371
                        $site_id = (string) get_option( 'edac_site_id', '' );
×
372
                        return '' !== $site_id;
×
373
                }
374

375
                return current_user_can( 'edit_posts' );
2✔
376
        }
377

378
        /**
379
         * REST handler to clear issues results for a given post ID.
380
         *
381
         * @param \WP_REST_Request $request  The request passed from the REST call.
382
         *
383
         * @return \WP_REST_Response
384
         */
385
        public function clear_issues_for_post( $request ) {
386

387
                if ( ! isset( $request['id'] ) ) {
4✔
388
                        return new \WP_REST_Response( [ 'message' => 'The ID is required to be passed.' ], 400 );
×
389
                }
390

391
                $json    = $request->get_json_params();
4✔
392
                $post_id = (int) $request['id'];
4✔
393
                if ( ! isset( $json['skip_post_exists_check'] ) ) {
4✔
394
                        $post = get_post( $post_id );
4✔
395
                        if ( ! is_object( $post ) ) {
4✔
396
                                return new \WP_REST_Response( [ 'message' => 'The post is not valid.' ], 400 );
×
397
                        }
398

399
                        $post_type  = get_post_type( $post );
4✔
400
                        $post_types = Settings::get_scannable_post_types();
4✔
401
                        if ( empty( $post_types ) || ! in_array( $post_type, $post_types, true ) ) {
4✔
402
                                return new \WP_REST_Response( [ 'message' => 'The post type is not set to be scanned.' ], 400 );
×
403
                        }
404
                }
405

406
                // if flush is set then clear the issues for that ID.
407
                if ( isset( $json['flush'] ) ) {
4✔
408
                        // purge the issues for this post.
409
                        Purge_Post_Data::delete_post( $post_id );
4✔
410
                }
411

412
                return new \WP_REST_Response(
4✔
413
                        [
4✔
414
                                'success' => true,
4✔
415
                                'flushed' => isset( $json['flush'] ),
4✔
416
                                'id'      => $post_id,
4✔
417
                        ]
4✔
418
                );
4✔
419
        }
420

421

422
        /**
423
         * Filter the html of the js validation violation.
424
         *
425
         * This can be used to store additional data in the html of the violation.
426
         *
427
         * @since 1.13.0
428
         * @param string $html      The html of the violation.
429
         * @param string $rule_id   The id of the rule.
430
         * @param array  $violation The violation data.
431
         *
432
         * @return string
433
         */
434
        public function filter_js_validation_html( string $html, string $rule_id, array $violation ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- the variable was used previously and will be used in future most likely.
435
                // Use just the opening <html> and closing </html> tag, prevents storing entire page as the affected code.
436
                if ( 'html-has-lang' === $rule_id || 'document-title' === $rule_id ) {
×
437
                        $html = preg_replace( '/^.*(<html.*?>).*(<\/html>).*$/s', '$1...$2', $html );
×
438

439
                }
440
                return $html;
×
441
        }
442

443
        /**
444
         * REST handler that saves to the DB a list of js rule violations for a post.
445
         *
446
         * @param WP_REST_Request $request  The request passed from the REST call.
447
         *
448
         * @return \WP_REST_Response
449
         */
450
        public function set_post_scan_results( $request ) {
451

452
                if ( ! isset( $request['violations'] ) ) {
4✔
453
                        return new \WP_REST_Response( [ 'message' => 'A required parameter is missing.' ], 400 );
×
454
                }
455

456
                $post_id = (int) $request['id'];
4✔
457
                $post    = get_post( $post_id );
4✔
458
                if ( ! is_object( $post ) ) {
4✔
459

460
                        return new \WP_REST_Response( [ 'message' => 'The post is not valid.' ], 400 );
×
461
                }
462

463
                $post_type  = get_post_type( $post );
4✔
464
                $post_types = Settings::get_scannable_post_types();
4✔
465
                if ( empty( $post_types ) || ! in_array( $post_type, $post_types, true ) ) {
4✔
466

467
                        return new \WP_REST_Response( [ 'message' => 'The post type is not set to be scanned.' ], 400 );
×
468

469
                }
470

471
                //phpcs:ignore Generic.Commenting.Todo.TaskFound
472
                // TODO: setup a rules class for loading/filtering rules.
473
                $rules             = edac_register_rules();
4✔
474
                $js_rule_ids       = [];
4✔
475
                $combined_rule_ids = [];
4✔
476
                foreach ( $rules as $rule ) {
4✔
477
                        if ( array_key_exists( 'ruleset', $rule ) && 'js' === $rule['ruleset'] ) {
4✔
478
                                $js_rule_ids[] = $rule['slug'];
4✔
479

480
                                // Some rules can be a grouping of other checks with different ids. This tracks those combined check IDs for later mapping.
481
                                if ( array_key_exists( 'combines', $rule ) && ! empty( $rule['combines'] ) ) {
4✔
482
                                        foreach ( $rule['combines'] as $combine_rule_id ) {
4✔
483
                                                $combined_rule_ids[ $combine_rule_id ] = $rule['slug'];
4✔
484
                                        }
485
                                }
486
                        }
487
                }
488

489
                try {
490

491
                        /**
492
                         * Fires before the validation process starts.
493
                         *
494
                         * This is only running in the JS check context.
495
                         *
496
                         * @since 1.5.0
497
                         *
498
                         * @param int    $post_id The post ID.
499
                         * @param string $type    The type of validation which is always 'js' in this path.
500
                         */
501
                        do_action( 'edac_before_validate', $post_id, 'js' );
4✔
502

503
                        $violations = $request['violations'];
4✔
504

505
                        // set record check flag on previous error records.
506
                        edac_remove_corrected_posts( $post_id, $post->post_type, $pre = 1, 'js' );
4✔
507

508
                        if ( is_array( $violations ) && count( $violations ) > 0 ) {
4✔
509

510
                                foreach ( $violations as $violation ) {
4✔
511
                                        $rule_id = $violation['ruleId'];
4✔
512

513
                                        // If this rule is a combined rule then map it to the actual reporting rule ID.
514
                                        $actual_rule_id = array_key_exists( $rule_id, $combined_rule_ids ) ? $combined_rule_ids[ $rule_id ] : $rule_id;
4✔
515

516
                                        if ( in_array( $actual_rule_id, $js_rule_ids, true ) ) {
4✔
517

518
                                                // This rule is one that we've included in our js ruleset.
519

520
                                                $html   = apply_filters( 'edac_filter_js_violation_html', $violation['html'], $rule_id, $violation );
×
521
                                                $impact = $violation['impact']; // by default, use the impact setting from the js rule.
×
522

523
                                                //phpcs:ignore Generic.Commenting.Todo.TaskFound
524
                                                // TODO: setup a rules class for loading/filtering rules.
525
                                                foreach ( $rules as $rule ) {
×
526
                                                        if ( $rule['slug'] === $actual_rule_id ) {
×
527
                                                                $impact = $rule['rule_type']; // if we are defining the rule_type in php rules config, use that instead of the js rule's impact setting.
×
528
                                                        }
529
                                                }
530

531
                                                //phpcs:ignore Generic.Commenting.Todo.TaskFound, Squiz.PHP.CommentedOutCode.Found
532
                                                // TODO: add support storing $violation['selector'], $violation['tags'].
533

534
                                                /**
535
                                                 * Fires before a rule is run against the content.
536
                                                 *
537
                                                 * This is only running in the JS check context.
538
                                                 *
539
                                                 * @since 1.5.0
540
                                                 *
541
                                                 * @param int    $post_id The post ID.
542
                                                 * @param string $rule_id The rule ID.
543
                                                 * @param string $type    The type of validation which is always 'js' in this path.
544
                                                 */
545
                                                do_action( 'edac_before_rule', $post_id, $actual_rule_id, 'js' );
×
546

547
                                                $landmark          = $violation['landmark'] ?? null;
×
548
                                                $landmark_selector = $violation['landmarkSelector'] ?? null;
×
549

550
                                                $selectors = [
×
551
                                                        'selector' => $violation['selector'] ?? [],
×
552
                                                        'ancestry' => $violation['ancestry'] ?? [],
×
553
                                                        'xpath'    => $violation['xpath'] ?? [],
×
554
                                                ];
×
555
                                                ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html, $landmark, $landmark_selector, $selectors );
×
556

557
                                                /**
558
                                                 * Fires after a rule is run against the content.
559
                                                 *
560
                                                 * This is only running in the JS check context.
561
                                                 *
562
                                                 * @since 1.5.0
563
                                                 *
564
                                                 * @param int    $post_id The post ID.
565
                                                 * @param string $rule_id The rule ID.
566
                                                 * @param string $type    The type of validation which is always 'js' in this path.
567
                                                 */
568
                                                do_action( 'edac_after_rule', $post_id, $actual_rule_id, 'js' );
×
569

570
                                        }
571
                                }
572
                        }
573

574
                        /**
575
                         * Fires after the validation process is complete.
576
                         *
577
                         * This is only running in the JS check context.
578
                         *
579
                         * @since 1.5.0
580
                         *
581
                         * @param int    $post_id The post ID.
582
                         * @param string $type    The type of validation which is always 'js' in this path.
583
                         */
584
                        do_action( 'edac_after_validate', $post_id, 'js' );
4✔
585

586
                        // remove corrected records.
587
                        edac_remove_corrected_posts( $post_id, $post->post_type, $pre = 2, 'js' );
4✔
588

589
                        // Save the density metrics before the summary is generated.
590
                        $metrics = $request['densityMetrics'] ?? [ 0, 0 ];
4✔
591
                        if ( is_array( $metrics ) && count( $metrics ) > 0 ) {
4✔
592
                                update_post_meta(
4✔
593
                                        $post_id,
4✔
594
                                        '_edac_density_data',
4✔
595
                                        [
4✔
596
                                                $metrics['elementCount'] ?? 0,
4✔
597
                                                $metrics['contentLength'] ?? 0,
4✔
598
                                        ]
4✔
599
                                );
4✔
600
                        }
601

602
                        // Update the summary info that is stored in meta this post.
603
                        ( new Summary_Generator( $post_id ) )->generate_summary();
4✔
604

605
                        // store a record of this scan in the post's meta.
606
                        update_post_meta( $post_id, '_edac_post_checked_js', time() );
4✔
607

608
                        /**
609
                         * Fires before sending the REST response ending the validation process.
610
                         *
611
                         * @since 1.14.0
612
                         *
613
                         * @param int             $post_id The post ID.
614
                         * @param string          $type    The type of validation which is always 'js' in this path.
615
                         * @param WP_REST_Request $request The request passed from the REST call.
616
                         */
617
                        do_action( 'edac_validate_before_sending_rest_response', $post_id, 'js', $request );
4✔
618

619
                        return new \WP_REST_Response(
4✔
620
                                [
4✔
621
                                        'success'   => true,
4✔
622
                                        'id'        => $post_id,
4✔
623
                                        'timestamp' => time(),
4✔
624
                                ]
4✔
625
                        );
4✔
626

627
                } catch ( \Exception $ex ) {
×
628

629
                        return new \WP_REST_Response(
×
630
                                [
×
631
                                        'message' => $ex->getMessage(),
×
632
                                ],
×
633
                                500
×
634
                        );
×
635

636
                }
637
        }
638

639

640
        /**
641
         * REST handler that clears the cached stats about the scans
642
         *
643
         * @return \WP_REST_Response
644
         */
645
        public function clear_cached_scans_stats() {
646

647
                try {
648

649
                        // Clear the cache.
650
                        $scans_stats = new Scans_Stats();
2✔
651
                        $scans_stats->clear_cache();
2✔
652

653
                        // Prime the cache.
654
                        $scans_stats = new Scans_Stats();
2✔
655

656
                        return new \WP_REST_Response(
2✔
657
                                [
2✔
658
                                        'success' => true,
2✔
659
                                ]
2✔
660
                        );
2✔
661

662
                } catch ( \Exception $ex ) {
×
663

664
                        return new \WP_REST_Response(
×
665
                                [
×
666
                                        'message' => $ex->getMessage(),
×
667
                                ],
×
668
                                500
×
669
                        );
×
670

671
                }
672
        }
673

674
        /**
675
         * REST handler that gets stats about the scans
676
         *
677
         * @return \WP_REST_Response
678
         */
679
        public function get_scans_stats() {
680

681
                try {
682

683
                        $scans_stats = new Scans_Stats( 60 * 5 );
2✔
684
                        $stats       = $scans_stats->summary();
2✔
685

686
                        return new \WP_REST_Response(
2✔
687
                                [
2✔
688
                                        'success' => true,
2✔
689
                                        'stats'   => $stats,
2✔
690
                                ]
2✔
691
                        );
2✔
692

693
                } catch ( \Exception $ex ) {
×
694

695
                        return new \WP_REST_Response(
×
696
                                [
×
697
                                        'message' => $ex->getMessage(),
×
698
                                ],
×
699
                                500
×
700
                        );
×
701

702
                }
703
        }
704

705

706
        /**
707
         * REST handler that gets stats about the scans by post type
708
         *
709
         * @param WP_REST_Request $request The request passed from the REST call.
710
         *
711
         * @return \WP_REST_Response
712
         */
713
        public function get_scans_stats_by_post_type( $request ) {
714

715
                if ( ! isset( $request['slug'] ) ) {
2✔
716
                        return new \WP_REST_Response( [ 'message' => 'A required parameter is missing.' ], 400 );
×
717
                }
718

719
                try {
720

721
                        $post_type            = strval( $request['slug'] );
2✔
722
                        $scannable_post_types = Settings::get_scannable_post_types();
2✔
723

724
                        if ( in_array( $post_type, $scannable_post_types, true ) ) {
2✔
725

726
                                $scans_stats = new Scans_Stats( 60 * 5 );
2✔
727
                                $by_type     = $scans_stats->issues_summary_by_post_type( $post_type );
2✔
728

729
                                return new \WP_REST_Response(
2✔
730
                                        [
2✔
731
                                                'success' => true,
2✔
732
                                                'stats'   => $by_type,
2✔
733
                                        ]
2✔
734
                                );
2✔
735
                        }
736
                        return new \WP_REST_Response( [ 'message' => 'The post type is not set to be scanned.' ], 400 );
2✔
737
                } catch ( \Exception $ex ) {
×
738
                        return new \WP_REST_Response(
×
739
                                [
×
740
                                        'message' => $ex->getMessage(),
×
741
                                ],
×
742
                                500
×
743
                        );
×
744
                }
745
        }
746

747
        /**
748
         * REST handler that gets stats about the scans by post types
749
         *
750
         * @param WP_REST_Request $request The request passed from the REST call.
751
         *
752
         * @return \WP_REST_Response
753
         */
754
        public function get_scans_stats_by_post_types( $request ) { //phpcs:ignore
755

756
                try {
757

758
                        $scans_stats = new Scans_Stats( 60 * 5 );
2✔
759

760
                        $scannable_post_types = Settings::get_scannable_post_types();
2✔
761

762
                        $post_types = get_post_types(
2✔
763
                                [
2✔
764
                                        'public' => true,
2✔
765
                                ]
2✔
766
                        );
2✔
767
                        unset( $post_types['attachment'] );
2✔
768

769
                        $post_types_to_check = array_merge( [ 'post', 'page' ], $scannable_post_types );
2✔
770

771
                        $by_types = [];
2✔
772

773
                        foreach ( $post_types as $post_type ) {
2✔
774

775
                                $by_types[ $post_type ] = false;
2✔
776
                                if ( in_array( $post_type, $scannable_post_types, true ) && in_array( $post_type, $post_types_to_check, true ) ) {
2✔
777
                                        $by_types[ $post_type ] = $scans_stats->issues_summary_by_post_type( $post_type );
2✔
778
                                }
779
                        }
780

781
                        return new \WP_REST_Response(
2✔
782
                                [
2✔
783
                                        'success' => true,
2✔
784
                                        'stats'   => $by_types,
2✔
785
                                ]
2✔
786
                        );
2✔
787

788
                } catch ( \Exception $ex ) {
×
789

790
                        return new \WP_REST_Response(
×
791
                                [
×
792
                                        'message' => $ex->getMessage(),
×
793
                                ],
×
794
                                500
×
795
                        );
×
796

797
                }
798
        }
799

800
        /**
801
         * REST handler that gets stats about the scans
802
         *
803
         * @param \WP_REST_Request $request The request passed from the REST call.
804
         *
805
         * @return \WP_REST_Response
806
         */
807
        public function get_site_summary( \WP_REST_Request $request ) {
808

809
                try {
810
                        $scan_stats = new Scans_Stats();
×
811
                        if ( (bool) $request->get_param( 'clearCache' ) ) {
×
812
                                $scan_stats->clear_cache();
×
813
                        }
814

815
                        return new \WP_REST_Response(
×
816
                                [
×
817
                                        'success' => true,
×
818
                                        'stats'   => $scan_stats->summary(),
×
819
                                ]
×
820
                        );
×
821
                } catch ( \Exception $ex ) {
×
822
                        return new \WP_REST_Response(
×
823
                                [
×
824
                                        'message' => $ex->getMessage(),
×
825
                                ],
×
826
                                500
×
827
                        );
×
828
                }
829
        }
830

831
        /**
832
         * REST handler that gets all sidebar data for a post (summary, details, readability).
833
         *
834
         * @since 1.xx.x
835
         *
836
         * @param \WP_REST_Request $request The request passed from the REST call.
837
         *
838
         * @return \WP_REST_Response
839
         */
840
        public function get_sidebar_data( \WP_REST_Request $request ) {
841
                $post_id = (int) $request['id'];
8✔
842

843
                try {
844
                        $data = [
8✔
845
                                'post_id'     => $post_id,
8✔
846
                                'summary'     => $this->get_summary_data( $post_id ),
8✔
847
                                'details'     => $this->get_details_data( $post_id ),
8✔
848
                                'readability' => $this->get_readability_data( $post_id ),
8✔
849
                        ];
8✔
850

851
                        return new \WP_REST_Response(
8✔
852
                                [
8✔
853
                                        'success' => true,
8✔
854
                                        'data'    => $data,
8✔
855
                                ],
8✔
856
                                200
8✔
857
                        );
8✔
858
                } catch ( \Exception $ex ) {
×
859
                        return new \WP_REST_Response(
×
860
                                [
×
861
                                        'success' => false,
×
862
                                        'message' => $ex->getMessage(),
×
863
                                ],
×
864
                                500
×
865
                        );
×
866
                }
867
        }
868

869
        /**
870
         * Get summary data for a post.
871
         *
872
         * Returns cached summary data from post meta. If no cache exists, returns defaults.
873
         *
874
         * @since 1.xx.x
875
         *
876
         * @param int $post_id The post ID.
877
         *
878
         * @return array
879
         */
880
        private function get_summary_data( $post_id ) {
881
                // Get summary from post meta.
882
                $summary = get_post_meta( $post_id, '_edac_summary', true );
12✔
883

884
                // If summary doesn't exist or is invalid, return defaults.
885
                if ( ! $summary || ! is_array( $summary ) ) {
12✔
886
                        $summary = [
10✔
887
                                'passed_tests'    => 0,
10✔
888
                                'errors'          => 0,
10✔
889
                                'contrast_errors' => 0,
10✔
890
                                'warnings'        => 0,
10✔
891
                                'ignored'         => 0,
10✔
892
                                'readability'     => 0,
10✔
893
                        ];
10✔
894
                }
895

896
                return $summary;
12✔
897
        }
898

899
        /**
900
         * Get details data for a post (errors, warnings, passed rules).
901
         *
902
         * @since 1.xx.x
903
         *
904
         * @param int $post_id The post ID.
905
         *
906
         * @throws \Exception If the database table name is invalid.
907
         * @return array
908
         */
909
        private function get_details_data( $post_id ) {
910
                global $wpdb;
10✔
911
                $table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' );
10✔
912
                $siteid     = get_current_blog_id();
10✔
913

914
                if ( ! $table_name ) {
10✔
915
                        throw new \Exception( esc_html__( 'Invalid table name', 'accessibility-checker' ) );
×
916
                }
917

918
                $rules = edac_register_rules();
10✔
919
                if ( ! $rules ) {
10✔
920
                        return [
×
921
                                'errors'   => [],
×
922
                                'warnings' => [],
×
923
                                'passed'   => [],
×
924
                        ];
×
925
                }
926

927
                if ( ! current_user_can( apply_filters( 'edac_filter_settings_capability', 'manage_options' ) ) ) {
10✔
928
                        foreach ( $rules as $rule_key => $rule ) {
2✔
929
                                if ( isset( $rule['fixes'] ) ) {
2✔
930
                                        unset( $rules[ $rule_key ]['fixes'] );
2✔
931
                                }
932
                        }
933
                }
934

935
                // If ANWW is active remove link_blank for details.
936
                if ( defined( 'ANWW_VERSION' ) ) {
10✔
937
                        $rules = edac_remove_element_with_value( $rules, 'slug', 'link_blank' );
×
938
                }
939

940
                $passed_rules  = [];
10✔
941
                $error_rules   = edac_filter_by_value( $rules, 'rule_type', 'error' );
10✔
942
                $warning_rules = edac_filter_by_value( $rules, 'rule_type', 'warning' );
10✔
943

944
                // Process both error and warning rules.
945
                $error_rules   = $this->process_rules_for_details( $error_rules, $post_id, $table_name, $siteid, $passed_rules );
10✔
946
                $warning_rules = $this->process_rules_for_details( $warning_rules, $post_id, $table_name, $siteid, $passed_rules );
10✔
947

948
                return [
10✔
949
                        'errors'   => array_values( $error_rules ),
10✔
950
                        'warnings' => array_values( $warning_rules ),
10✔
951
                        'passed'   => $passed_rules,
10✔
952
                ];
10✔
953
        }
954

955
        /**
956
         * Process rules and fetch issue details from the database.
957
         *
958
         * @since 1.xx.x
959
         *
960
         * @param array  $rules         The rules to process.
961
         * @param int    $post_id       The post ID.
962
         * @param string $table_name    The database table name.
963
         * @param int    $siteid        The site/blog ID.
964
         * @param array  &$passed_rules Reference to passed rules array (populated by this method).
965
         *
966
         * @return array The processed rules with counts and details.
967
         */
968
        private function process_rules_for_details( $rules, $post_id, $table_name, $siteid, &$passed_rules ) {
969
                global $wpdb;
10✔
970
                static $user_cache = [];
10✔
971

972
                // Early return if no rules to process.
973
                if ( empty( $rules ) ) {
10✔
974
                        return $rules;
×
975
                }
976

977
                // Extract rule slugs for IN clause.
978
                $rule_slugs = array_column( $rules, 'slug' );
10✔
979

980
                // Build a simple, escaped IN clause.
981
                $safe_table    = esc_sql( $table_name );
10✔
982
                $escaped_slugs = array_map( 'esc_sql', $rule_slugs );
10✔
983
                $in_clause     = "'" . implode( "','", $escaped_slugs ) . "'";
10✔
984

985
                // Direct SQL query (table and values already escaped).
986
                $sql = "SELECT *\n"
10✔
987
                        . "FROM `{$safe_table}`\n"
10✔
988
                        . "WHERE postid = {$post_id}\n"
10✔
989
                        . "AND rule IN ( {$in_clause} )\n"
10✔
990
                        . "AND siteid = {$siteid}";
10✔
991

992
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
993
                $all_results = $wpdb->get_results( $sql, ARRAY_A );
10✔
994

995
                // Group results by rule slug.
996
                $results_by_rule = [];
10✔
997
                foreach ( $all_results as $result ) {
10✔
998
                        $rule_slug = $result['rule'];
2✔
999
                        if ( ! isset( $results_by_rule[ $rule_slug ] ) ) {
2✔
1000
                                $results_by_rule[ $rule_slug ] = [];
2✔
1001
                        }
1002
                        // If we have non-zero ignre_user then get the username.
1003
                        if ( isset( $result['ignre_user'] ) && (int) $result['ignre_user'] > 0 ) {
2✔
1004
                                $user_id = (int) $result['ignre_user'];
×
1005
                                if ( ! array_key_exists( $user_id, $user_cache ) ) {
×
1006
                                        $user_info              = get_userdata( $user_id );
×
1007
                                        $user_cache[ $user_id ] = $user_info ? $user_info->user_login : __( 'Unknown', 'accessibility-checker' );
×
1008
                                }
1009
                                $result['ignre_user_name'] = $user_cache[ $user_id ];
×
1010
                        }
1011
                        $results_by_rule[ $rule_slug ][] = $result;
2✔
1012
                }
1013

1014
                // Process each rule with its results.
1015
                foreach ( $rules as $key => $rule ) {
10✔
1016
                        $rule_slug = $rule['slug'];
10✔
1017
                        $results   = $results_by_rule[ $rule_slug ] ?? [];
10✔
1018
                        $count     = count( $results );
10✔
1019

1020
                        if ( $count ) {
10✔
1021
                                $rules[ $key ]['count']   = $count;
2✔
1022
                                $rules[ $key ]['details'] = $results;
2✔
1023
                                // Add WCAG URL based on wcag number.
1024
                                if ( isset( $rule['wcag'] ) ) {
2✔
1025
                                        $rules[ $key ] += $this->get_wcag_url_and_title_from_number( $rule['wcag'] );
2✔
1026
                                }
1027
                        } else {
1028
                                $rule['count']  = 0;
10✔
1029
                                $passed_rules[] = $rule;
10✔
1030
                                unset( $rules[ $key ] );
10✔
1031
                        }
1032
                }
1033

1034
                return $rules;
10✔
1035
        }
1036

1037
        /**
1038
         * Get readability data for a post.
1039
         *
1040
         * @since 1.xx.x
1041
         *
1042
         * @param int $post_id The post ID.
1043
         *
1044
         * @throws \Exception If the post is not found.
1045
         * @return array
1046
         */
1047
        private function get_readability_data( $post_id ) {
1048
                $simplified_summary          = (string) get_post_meta( $post_id, '_edac_simplified_summary', true );
8✔
1049
                $simplified_summary_position = get_option( 'edac_simplified_summary_position', false );
8✔
1050

1051
                $content_post = get_post( $post_id );
8✔
1052
                if ( ! $content_post ) {
8✔
1053
                        throw new \Exception( esc_html__( 'Post not found', 'accessibility-checker' ) );
×
1054
                }
1055

1056
                $content = $content_post->post_content;
8✔
1057
                $content = apply_filters( 'the_content', $content );
8✔
1058

1059
                /**
1060
                 * Filter the content used for reading grade readability analysis.
1061
                 *
1062
                 * @since 1.4.0
1063
                 *
1064
                 * @param string $content The content to be filtered.
1065
                 * @param int    $post_id The post ID.
1066
                 */
1067
                $content = apply_filters( 'edac_filter_readability_content', $content, $post_id );
8✔
1068
                $content = wp_filter_nohtml_kses( $content );
8✔
1069
                $content = str_replace( ']]>', ']]&gt;', $content );
8✔
1070

1071
                // Get readability metadata.
1072
                $edac_summary           = get_post_meta( $post_id, '_edac_summary', true );
8✔
1073
                $post_grade_readability = isset( $edac_summary['readability'] ) ? $edac_summary['readability'] : 0;
8✔
1074
                $post_grade             = (int) filter_var( $post_grade_readability, FILTER_SANITIZE_NUMBER_INT );
8✔
1075
                $post_grade_failed      = $post_grade > 9; // Treat Flesch-Kincaid grade 9+ (above roughly 8th-grade reading level recommended for plain language) as a readability failure.
8✔
1076

1077
                $simplified_summary_grade = 0;
8✔
1078
                if ( class_exists( 'DaveChild\TextStatistics\TextStatistics' ) ) {
8✔
1079
                        $text_statistics          = new \DaveChild\TextStatistics\TextStatistics();
8✔
1080
                        $simplified_summary_grade = (int) floor( $text_statistics->fleschKincaidGradeLevel( $simplified_summary ) );
8✔
1081
                }
1082

1083
                $simplified_summary_grade_failed      = $simplified_summary_grade >= 9;
8✔
1084
                $simplified_summary_grade_readability = edac_ordinal( $simplified_summary_grade );
8✔
1085
                $simplified_summary_prompt            = get_option( 'edac_simplified_summary_prompt' );
8✔
1086

1087
                return [
8✔
1088
                        'post_grade'                           => $post_grade,
8✔
1089
                        'post_grade_readability'               => $post_grade_readability,
8✔
1090
                        'post_grade_failed'                    => $post_grade_failed,
8✔
1091
                        'simplified_summary'                   => $simplified_summary,
8✔
1092
                        'simplified_summary_grade'             => $simplified_summary_grade,
8✔
1093
                        'simplified_summary_grade_readability' => $simplified_summary_grade_readability,
8✔
1094
                        'simplified_summary_grade_failed'      => $simplified_summary_grade_failed,
8✔
1095
                        'simplified_summary_prompt'            => $simplified_summary_prompt,
8✔
1096
                        'simplified_summary_position'          => $simplified_summary_position,
8✔
1097
                        'content_length'                       => strlen( $content ),
8✔
1098
                ];
8✔
1099
        }
1100

1101
        /**
1102
         * Save simplified summary for a post.
1103
         *
1104
         * @since 1.xx.x
1105
         *
1106
         * @param \WP_REST_Request $request The REST request object.
1107
         * @return \WP_REST_Response|\WP_Error
1108
         */
1109
        public function save_simplified_summary( \WP_REST_Request $request ) {
1110
                $post_id = (int) $request['id'];
×
1111
                $summary = sanitize_textarea_field( wp_unslash( $request['summary'] ) );
×
1112

1113
                // Update the post meta with the simplified summary (matching AJAX behavior).
1114
                update_post_meta(
×
1115
                        $post_id,
×
1116
                        '_edac_simplified_summary',
×
1117
                        $summary
×
1118
                );
×
1119

1120
                // Get the complete readability data structure (same as the main readability endpoint).
1121
                try {
1122
                        $readability_data = $this->get_readability_data( $post_id );
×
1123

1124
                        // Return data structure that matches the readability endpoint format.
1125
                        return new \WP_REST_Response(
×
1126
                                [
×
1127
                                        'success'                              => true,
×
1128
                                        'post_grade'                           => $readability_data['post_grade'],
×
1129
                                        'post_grade_readability'               => $readability_data['post_grade_readability'],
×
1130
                                        'post_grade_failed'                    => $readability_data['post_grade_failed'],
×
1131
                                        'simplified_summary'                   => $readability_data['simplified_summary'],
×
1132
                                        'simplified_summary_grade'             => $readability_data['simplified_summary_grade'],
×
1133
                                        'simplified_summary_grade_readability' => $readability_data['simplified_summary_grade_readability'],
×
1134
                                        'simplified_summary_grade_failed'      => $readability_data['simplified_summary_grade_failed'],
×
1135
                                        'simplified_summary_prompt'            => $readability_data['simplified_summary_prompt'],
×
1136
                                        'simplified_summary_position'          => $readability_data['simplified_summary_position'],
×
1137
                                        'content_length'                       => $readability_data['content_length'],
×
1138
                                ],
×
1139
                                200
×
1140
                        );
×
1141
                } catch ( \Exception $e ) {
×
1142
                        return new \WP_Error(
×
1143
                                'readability_data_error',
×
1144
                                $e->getMessage(),
×
1145
                                [ 'status' => 500 ]
×
1146
                        );
×
1147
                }
1148
        }
1149

1150
        /**
1151
         * Get WCAG URL from wcag number
1152
         *
1153
         * @param string $wcag_number The WCAG number (e.g., '1.1.1').
1154
         * @return array An array containing 'wcag_title' and 'wcag_url' keys. Both values will be empty strings if the WCAG number is not found.
1155
         */
1156
        private function get_wcag_url_and_title_from_number( $wcag_number ) {
1157
                $wcag_data_to_return = [
2✔
1158
                        'wcag_title' => '',
2✔
1159
                        'wcag_url'   => '',
2✔
1160
                ];
2✔
1161

1162
                if ( ! $wcag_number ) {
2✔
1163
                        return $wcag_data_to_return;
×
1164
                }
1165

1166
                static $wcag_lookup = null;
2✔
1167

1168
                if ( null === $wcag_lookup ) {
2✔
1169
                        // Load the WCAG data file.
1170
                        $wcag_file = EDAC_PLUGIN_DIR . 'includes/wcag.php';
2✔
1171
                        if ( ! file_exists( $wcag_file ) ) {
2✔
1172
                                $wcag_lookup = [];
×
1173
                                return $wcag_data_to_return;
×
1174
                        }
1175

1176
                        $wcag_data = include $wcag_file;
2✔
1177
                        if ( ! is_array( $wcag_data ) ) {
2✔
1178
                                $wcag_lookup = [];
×
1179
                                return $wcag_data_to_return;
×
1180
                        }
1181

1182
                        // Re-key the array by WCAG number for O(1) lookups.
1183
                        $wcag_lookup = array_column( $wcag_data, null, 'number' );
2✔
1184
                }
1185

1186
                // O(1) lookup by WCAG number.
1187
                if ( isset( $wcag_lookup[ $wcag_number ] ) ) {
2✔
1188
                        $entry               = $wcag_lookup[ $wcag_number ];
2✔
1189
                        $wcag_data_to_return = [
2✔
1190
                                'wcag_title' => $entry['title'] ?? '',
2✔
1191
                                'wcag_url'   => $entry['wcag_url'] ?? '',
2✔
1192
                        ];
2✔
1193
                }
1194

1195
                return $wcag_data_to_return;
2✔
1196
        }
1197

1198
        /**
1199
         * REST handler for dismissing or restoring an issue.
1200
         *
1201
         * @param \WP_REST_Request $request The request object.
1202
         * @return \WP_REST_Response|\WP_Error Response object on success, WP_Error on failure.
1203
         */
1204
        public function dismiss_issue( $request ) {
1205
                global $wpdb;
6✔
1206

1207
                $issue_id      = (int) $request['issue_id'];
6✔
1208
                $action        = $request->get_param( 'action' );
6✔
1209
                $reason        = $request->get_param( 'reason' ) ?? '';
6✔
1210
                $comment       = $request->get_param( 'comment' ) ?? '';
6✔
1211
                $ignore_global = $request->get_param( 'ignore_global' ) ?? 0;
6✔
1212
                $large_batch   = $request->get_param( 'largeBatch' ) ?? false;
6✔
1213

1214
                $table_name = $wpdb->prefix . 'accessibility_checker';
6✔
1215
                $site_id    = get_current_blog_id();
6✔
1216

1217
                $allowed_ignore_actions = [ 'enable', 'ignore', 'dismiss' ];
6✔
1218
                // Set values based on action (matching AJAX endpoint behavior).
1219
                $is_ignoring          = in_array( $action, $allowed_ignore_actions, true ); // old systems send 'enable' when ignoring. This handles both for back compat but 'enable' is very unclear and should be swapped.
6✔
1220
                $ignre                = $is_ignoring ? 1 : 0;
6✔
1221
                $ignre_user           = $is_ignoring ? get_current_user_id() : null;
6✔
1222
                $ignre_user_info      = $is_ignoring ? get_userdata( $ignre_user ) : null;
6✔
1223
                $ignre_username       = $is_ignoring && $ignre_user_info ? $ignre_user_info->user_login : '';
6✔
1224
                $ignre_date           = $is_ignoring ? edac_get_current_utc_datetime() : null;
6✔
1225
                $ignre_date_formatted = $is_ignoring ? edac_format_datetime_from_utc( $ignre_date ) : '';
6✔
1226
                $ignre_reason         = $is_ignoring ? $reason : null;
6✔
1227
                $ignre_comment        = $is_ignoring ? $comment : null;
6✔
1228
                $ignre_global         = $is_ignoring ? (int) $ignore_global : 0;
6✔
1229

1230
                // If largeBatch is set, verify edit permission for all matching rows,
1231
                // then perform a single object-based update.
1232
                if ( $large_batch ) {
6✔
1233
                        // Get the 'object' from the issue id.
1234
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Need fresh data.
1235
                        $object = $wpdb->get_var( $wpdb->prepare( 'SELECT object FROM %i WHERE id = %d', $table_name, $issue_id ) );
4✔
1236

1237
                        if ( ! $object ) {
4✔
1238
                                return new \WP_Error(
×
1239
                                        'issue_not_found',
×
1240
                                        __( 'Issue not found.', 'accessibility-checker' ),
×
1241
                                        [ 'status' => 404 ]
×
1242
                                );
×
1243
                        }
1244

1245
                        // Load all matching issue IDs and post IDs so we can permission-gate
1246
                        // every issue in the batch before doing one bulk query.
1247
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Need current rows for permission validation.
1248
                        $issue_rows = $wpdb->get_results(
4✔
1249
                                $wpdb->prepare(
4✔
1250
                                        'SELECT id, postid FROM %i WHERE siteid = %d AND object = %s',
4✔
1251
                                        $table_name,
4✔
1252
                                        $site_id,
4✔
1253
                                        $object
4✔
1254
                                ),
4✔
1255
                                ARRAY_A
4✔
1256
                        );
4✔
1257

1258
                        if ( empty( $issue_rows ) ) {
4✔
1259
                                return new \WP_Error(
×
1260
                                        'issue_not_found',
×
UNCOV
1261
                                        __( 'Issue not found.', 'accessibility-checker' ),
×
UNCOV
1262
                                        [ 'status' => 404 ]
×
UNCOV
1263
                                );
×
1264
                        }
1265

1266
                        foreach ( $issue_rows as $issue_row ) {
4✔
1267
                                $post_id = isset( $issue_row['postid'] ) ? (int) $issue_row['postid'] : 0;
4✔
1268
                                if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
4✔
1269
                                        return new \WP_Error(
2✔
1270
                                                'rest_forbidden',
2✔
1271
                                                __( 'Sorry, you are not allowed to dismiss one or more issues in this batch.', 'accessibility-checker' ),
2✔
1272
                                                [ 'status' => rest_authorization_required_code() ]
2✔
1273
                                        );
2✔
1274
                                }
1275
                        }
1276

1277
                        // Build an explicit list of vetted IDs so the UPDATE targets only rows we already permission-checked.
1278
                        $issue_ids       = array_map( 'intval', wp_list_pluck( $issue_rows, 'id' ) );
2✔
1279
                        $id_placeholders = implode( ', ', array_fill( 0, count( $issue_ids ), '%d' ) );
2✔
1280

1281
                        // Update only the exact vetted issue IDs in one query after auth checks pass.
1282
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct update required, no caching needed.
1283
                        $result = $wpdb->query(
2✔
1284
                                // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder count is dynamic; $id_placeholders contains only %d tokens, no user input.
1285
                                $wpdb->prepare(
2✔
1286
                                        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is built from a static string concatenated with %d-only placeholders; no user input is interpolated.
1287
                                        'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_reason = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d AND id IN ( ' . $id_placeholders . ' )',
2✔
1288
                                        array_merge(
2✔
1289
                                                [
2✔
1290
                                                        $table_name,
2✔
1291
                                                        $ignre,
2✔
1292
                                                        $ignre_user,
2✔
1293
                                                        $ignre_date,
2✔
1294
                                                        $ignre_reason,
2✔
1295
                                                        $ignre_comment,
2✔
1296
                                                        $ignre_global,
2✔
1297
                                                        $site_id,
2✔
1298
                                                ],
2✔
1299
                                                $issue_ids
2✔
1300
                                        )
2✔
1301
                                )
2✔
1302
                        );
2✔
1303
                } else {
1304
                        // Update single issue by ID.
1305
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct update required, no caching needed.
1306
                        $result = $wpdb->query(
2✔
1307
                                $wpdb->prepare(
2✔
1308
                                        'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_reason = %s, ignre_comment = %s, ignre_global = %d WHERE siteid = %d AND id = %d',
2✔
1309
                                        $table_name,
2✔
1310
                                        $ignre,
2✔
1311
                                        $ignre_user,
2✔
1312
                                        $ignre_date,
2✔
1313
                                        $ignre_reason,
2✔
1314
                                        $ignre_comment,
2✔
1315
                                        $ignre_global,
2✔
1316
                                        $site_id,
2✔
1317
                                        $issue_id
2✔
1318
                                )
2✔
1319
                        );
2✔
1320
                }
1321

1322
                if ( false === $result ) {
4✔
UNCOV
1323
                        return new \WP_Error(
×
UNCOV
1324
                                'database_error',
×
UNCOV
1325
                                __( 'Failed to update the issue.', 'accessibility-checker' ),
×
UNCOV
1326
                                [ 'status' => 500 ]
×
UNCOV
1327
                        );
×
1328
                }
1329

1330
                return new \WP_REST_Response(
4✔
1331
                        [
4✔
1332
                                'success'         => true,
4✔
1333
                                'issue_id'        => $issue_id,
4✔
1334
                                'action'          => $action,
4✔
1335
                                'ignre'           => $is_ignoring,
4✔
1336
                                'ignre_global'    => $ignre_global,
4✔
1337
                                'ignre_user'      => $ignre_user,
4✔
1338
                                'ignre_user_name' => $ignre_username,
4✔
1339
                                'ignre_date'      => $ignre_date_formatted,
4✔
1340
                                'ignre_reason'    => $ignre_reason,
4✔
1341
                                'ignre_comment'   => $ignre_comment,
4✔
1342
                                'large_batch'     => $large_batch,
4✔
1343
                        ],
4✔
1344
                        200
4✔
1345
                );
4✔
1346
        }
1347
}
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