settings = AccessIQ_Settings::instance(); add_action( 'wp_ajax_accessiq_scan_page', [ $this, 'ajax_scan_page' ] ); add_action( 'wp_ajax_accessiq_save_results', [ $this, 'ajax_save_results' ] ); add_action( 'wp_ajax_accessiq_get_scans', [ $this, 'ajax_get_scans' ] ); add_action( 'wp_ajax_accessiq_delete_scan', [ $this, 'ajax_delete_scan' ] ); } /** * AJAX: Initiate a page scan. * The actual DOM scanning happens in the browser (scanner-engine.js). * This endpoint validates the request and returns config to the JS. */ public function ajax_scan_page() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_die(); $url = esc_url_raw( wp_unslash( $_POST['url'] ?? '' ) ); if ( ! $url ) { wp_send_json_error( [ 'message' => __( 'Invalid URL.', 'accessiq' ) ] ); } wp_send_json_success( [ 'url' => $url, 'level' => $this->settings->get( 'scan_level', 'AA' ), 'rules' => AccessIQ_WCAG_Rules::get_by_level( $this->settings->get( 'scan_level', 'AA' ) ), ] ); } /** * AJAX: Save scan results sent from browser JS after DOM scan. */ public function ajax_save_results() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! isset( $_POST['accessiq_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['accessiq_nonce'] ) ), 'accessiq_save_scan' ) ) { wp_send_json_error( [ 'message' => __( 'Security check failed.', 'accessiq' ) ], 403 ); } if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Unauthorized.', 'accessiq' ) ], 403 ); } global $wpdb; $url = esc_url_raw( wp_unslash( $_POST['url'] ?? '' ) ); $score = isset( $_POST['score'] ) ? absint( wp_unslash( $_POST['score'] ) ) : 0; $level = sanitize_text_field( wp_unslash( $_POST['level'] ?? 'AA' ) ); $accessiq_issues_raw = isset( $_POST['issues'] ) ? wp_unslash( $_POST['issues'] ) : '[]'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON string; each decoded field sanitized before DB insert. $accessiq_issues_decoded = json_decode( $accessiq_issues_raw, true ); $issues = array(); if ( is_array( $accessiq_issues_decoded ) ) { foreach ( $accessiq_issues_decoded as $accessiq_issue ) { if ( ! is_array( $accessiq_issue ) ) { continue; } $issues[] = array( 'wcag_id' => isset( $accessiq_issue['wcag_id'] ) ? sanitize_text_field( $accessiq_issue['wcag_id'] ) : '', 'level' => isset( $accessiq_issue['level'] ) ? sanitize_text_field( $accessiq_issue['level'] ) : 'A', 'severity' => isset( $accessiq_issue['severity'] ) ? sanitize_text_field( $accessiq_issue['severity'] ) : '', 'type' => isset( $accessiq_issue['type'] ) ? sanitize_text_field( $accessiq_issue['type'] ) : '', 'element' => isset( $accessiq_issue['element'] ) ? sanitize_textarea_field( $accessiq_issue['element'] ) : '', 'suggestion' => isset( $accessiq_issue['suggestion'] ) ? sanitize_textarea_field( $accessiq_issue['suggestion'] ) : '', ); } } if ( ! $url ) { wp_send_json_error( [ 'message' => 'Invalid data.' ] ); } // Create a CPT post to store the scan $scan_id = wp_insert_post( [ 'post_type' => ACCESSIQ_SCAN_CPT, 'post_title' => $url, 'post_status' => 'publish', ] ); if ( is_wp_error( $scan_id ) ) { wp_send_json_error( [ 'message' => 'Could not save scan.' ] ); } // Store summary as post meta update_post_meta( $scan_id, '_accessiq_url', $url ); update_post_meta( $scan_id, '_accessiq_score', $score ); update_post_meta( $scan_id, '_accessiq_level', $level ); update_post_meta( $scan_id, '_accessiq_total', count( $issues ) ); update_post_meta( $scan_id, '_accessiq_errors', count( array_filter( $issues, fn($i) => $i['severity'] === 'error' ) ) ); update_post_meta( $scan_id, '_accessiq_warnings', count( array_filter( $issues, fn($i) => $i['severity'] === 'warning' ) ) ); // Store individual issues in custom table $table = $wpdb->prefix . 'accessiq_issues'; foreach ( $issues as $issue ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table, [ 'scan_id' => $scan_id, 'wcag_id' => sanitize_text_field( $issue['wcag_id'] ?? '' ), 'level' => sanitize_text_field( $issue['level'] ?? 'A' ), 'element' => sanitize_text_field( $issue['element'] ?? '' ), 'issue_type' => sanitize_text_field( $issue['type'] ?? '' ), 'suggestion' => sanitize_textarea_field( $issue['suggestion'] ?? '' ), ] ); } wp_send_json_success( [ 'scan_id' => $scan_id, 'score' => $score, 'total' => count( $issues ), 'report_url' => wp_nonce_url( admin_url( 'admin.php?page=accessiq-reports&scan_id=' . intval( $scan_id ) ), 'accessiq_view_report' ), ] ); } /** * AJAX: Get list of past scans for the reports page. */ public function ajax_get_scans() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_die(); $scans = get_posts( [ 'post_type' => ACCESSIQ_SCAN_CPT, 'posts_per_page' => 50, 'orderby' => 'date', 'order' => 'DESC', ] ); $data = array_map( function( $scan ) { return [ 'id' => $scan->ID, 'url' => get_post_meta( $scan->ID, '_accessiq_url', true ), 'score' => get_post_meta( $scan->ID, '_accessiq_score', true ), 'total' => get_post_meta( $scan->ID, '_accessiq_total', true ), 'errors' => get_post_meta( $scan->ID, '_accessiq_errors', true ), 'warnings' => get_post_meta( $scan->ID, '_accessiq_warnings', true ), 'level' => get_post_meta( $scan->ID, '_accessiq_level', true ), 'date' => get_the_date( 'M j, Y — H:i', $scan ), 'report_url' => wp_nonce_url( admin_url( 'admin.php?page=accessiq-reports&scan_id=' . intval( $scan->ID ) ), 'accessiq_view_report' ), ]; }, $scans ); wp_send_json_success( $data ); } /** * AJAX: Delete a scan and its issues. */ public function ajax_delete_scan() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_die(); global $wpdb; $scan_id = intval( $_POST['scan_id'] ?? 0 ); if ( ! $scan_id ) wp_send_json_error(); wp_delete_post( $scan_id, true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( $wpdb->prefix . 'accessiq_issues', [ 'scan_id' => $scan_id ], [ '%d' ] ); wp_send_json_success(); } /** * Server-side scan entry point for Pro scheduled scans. * * Fetches the page via wp_remote_get, runs basic WCAG checks, * saves results as a scan CPT, and returns score data. * * @param string $url The URL to scan. * @return array|WP_Error Scan result array or WP_Error on failure. */ public static function run_scan_for_schedule( $url ) { $response = wp_remote_get( $url, array( 'timeout' => 30, 'sslverify' => false ) ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 400 ) { return new WP_Error( 'http_error', sprintf( 'HTTP %d for %s', $code, $url ) ); } $html = wp_remote_retrieve_body( $response ); if ( empty( $html ) ) { return new WP_Error( 'empty_body', 'Empty response body.' ); } $settings = AccessIQ_Settings::instance(); $level = $settings->get( 'scan_level', 'AA' ); $issues = self::analyse_html( $html, $level ); $errors = count( array_filter( $issues, function ( $i ) { return $i['severity'] === 'error'; } ) ); $warnings = count( $issues ) - $errors; $total = count( $issues ); $score = $total === 0 ? 100 : max( 0, 100 - ( $errors * 10 + $warnings * 3 ) ); global $wpdb; $scan_id = wp_insert_post( array( 'post_type' => ACCESSIQ_SCAN_CPT, 'post_title' => $url, 'post_status' => 'publish', ) ); if ( is_wp_error( $scan_id ) ) { return $scan_id; } update_post_meta( $scan_id, '_accessiq_url', $url ); update_post_meta( $scan_id, '_accessiq_score', $score ); update_post_meta( $scan_id, '_accessiq_level', $level ); update_post_meta( $scan_id, '_accessiq_total', $total ); update_post_meta( $scan_id, '_accessiq_errors', $errors ); update_post_meta( $scan_id, '_accessiq_warnings', $warnings ); $table = $wpdb->prefix . 'accessiq_issues'; foreach ( $issues as $issue ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table, array( 'scan_id' => $scan_id, 'wcag_id' => sanitize_text_field( $issue['wcag_id'] ), 'level' => sanitize_text_field( $issue['level'] ), 'element' => sanitize_text_field( $issue['element'] ), 'issue_type' => sanitize_text_field( $issue['type'] ), 'suggestion' => sanitize_textarea_field( $issue['suggestion'] ), 'severity' => sanitize_text_field( $issue['severity'] ), ) ); } return array( 'scan_id' => $scan_id, 'score' => $score, 'issues_count' => $total, ); } /** * Basic server-side HTML analysis for WCAG issues. * * @param string $html Raw HTML content. * @param string $level WCAG level (A or AA). * @return array Array of issue arrays. */ private static function analyse_html( $html, $level = 'AA' ) { $issues = array(); libxml_use_internal_errors( true ); $doc = new DOMDocument(); $doc->loadHTML( '' . $html, LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); $imgs = $doc->getElementsByTagName( 'img' ); foreach ( $imgs as $img ) { $alt = $img->getAttribute( 'alt' ); if ( $alt === '' && ! $img->hasAttribute( 'alt' ) || ( $img->hasAttribute( 'alt' ) && trim( $alt ) === '' && ! $img->hasAttribute( 'role' ) ) ) { $issues[] = array( 'wcag_id' => '1.1.1', 'level' => 'A', 'element' => self::truncate_element( $doc->saveHTML( $img ) ), 'type' => 'Missing alt text', 'suggestion' => 'Add descriptive text or aria-label to all images.', 'severity' => 'error', ); } } $links = $doc->getElementsByTagName( 'a' ); foreach ( $links as $link ) { $text = trim( $link->textContent ); if ( $text === '' && ! $link->getAttribute( 'aria-label' ) && ! $link->getAttribute( 'aria-labelledby' ) ) { $issues[] = array( 'wcag_id' => '2.4.4', 'level' => 'A', 'element' => self::truncate_element( $doc->saveHTML( $link ) ), 'type' => 'Empty link', 'suggestion' => 'Add descriptive text or aria-label to all links.', 'severity' => 'error', ); } } $headings = array(); for ( $i = 1; $i <= 6; $i++ ) { foreach ( $doc->getElementsByTagName( 'h' . $i ) as $h ) { $headings[] = $i; } } for ( $i = 1; $i < count( $headings ); $i++ ) { if ( $headings[ $i ] > $headings[ $i - 1 ] + 1 ) { $issues[] = array( 'wcag_id' => '1.3.1', 'level' => 'A', 'element' => sprintf( 'h%d → h%d (skipped)', $headings[ $i - 1 ], $headings[ $i ] ), 'type' => 'Heading level skipped', 'suggestion' => 'Use heading levels in sequential order without skipping.', 'severity' => 'warning', ); break; } } if ( 'AA' === $level ) { $html_tag = $doc->getElementsByTagName( 'html' )->item( 0 ); if ( $html_tag && ! $html_tag->getAttribute( 'lang' ) ) { $issues[] = array( 'wcag_id' => '3.1.1', 'level' => 'AA', 'element' => '', 'type' => 'Missing lang attribute', 'suggestion' => 'Add a lang attribute to the element.', 'severity' => 'error', ); } } return $issues; } private static function truncate_element( $html ) { $html = trim( preg_replace( '/\s+/', ' ', $html ) ); return strlen( $html ) > 120 ? substr( $html, 0, 117 ) . '...' : $html; } /** * Get issues for a specific scan (used by report page). */ public static function get_scan_issues( $scan_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}accessiq_issues WHERE scan_id = %d ORDER BY level ASC, wcag_id ASC", $scan_id ) ); } }