WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_result' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'url' => array( 'required' => true, 'type' => 'string', 'validate_callback' => array( $this, 'validate_same_site_url' ), ), 'title' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'violations' => array( 'required' => true, 'type' => 'array', ), 'passes' => array( 'type' => 'integer' ), 'incomplete' => array( 'type' => 'integer' ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/result', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_result' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'url' => array( 'required' => true, 'type' => 'string', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/results', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'list_results' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE_V1, '/alt-text', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_alt_text' ), 'permission_callback' => array( $this, 'can_edit_media' ), 'args' => array( 'id' => array( 'required' => true, 'type' => 'integer', ), 'alt' => array( 'required' => true, 'type' => 'string', ), ), ) ); } /** * Only administrators may run scans or read results. * * @return bool */ public function can_manage() { return current_user_can( 'manage_options' ); } /** * Editing alt text is a media capability, not an admin one. * * @return bool */ public function can_edit_media() { return current_user_can( 'upload_files' ); } /** * Reject any URL that is not on this site. * * Without this an administrator could be tricked into storing a report for * an arbitrary external address, and every screen that renders a stored URL * becomes a way to plant a link. * * @param string $value Candidate URL. * @return bool */ public function validate_same_site_url( $value ) { $value = esc_url_raw( (string) $value ); if ( '' === $value ) { return false; } $candidate = wp_parse_url( $value ); $home = wp_parse_url( home_url() ); if ( empty( $candidate['host'] ) || empty( $home['host'] ) ) { return false; } return strtolower( $candidate['host'] ) === strtolower( $home['host'] ); } /** * Store one page's scan result. * * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response */ public function save_result( WP_REST_Request $request ) { $url = esc_url_raw( $request->get_param( 'url' ) ); $violations = $request->get_param( 'violations' ); if ( ! is_array( $violations ) ) { $violations = array(); } $issues = array(); $counts = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0, ); $elements = 0; foreach ( $violations as $violation ) { $issue = $this->normalise_violation( $violation ); if ( null === $issue ) { continue; } $issues[] = $issue; $elements += $issue['count']; if ( ! $issue['bestPracticeOnly'] && isset( $counts[ $issue['impact'] ] ) ) { $counts[ $issue['impact'] ]++; } } usort( $issues, static function ( $a, $b ) { $order = array( 'critical' => 0, 'serious' => 1, 'moderate' => 2, 'minor' => 3, ); $left = isset( $order[ $a['impact'] ] ) ? $order[ $a['impact'] ] : 4; $right = isset( $order[ $b['impact'] ] ) ? $order[ $b['impact'] ] : 4; if ( $left !== $right ) { return $left <=> $right; } return $b['count'] <=> $a['count']; } ); $result = array( 'url' => $url, 'title' => (string) $request->get_param( 'title' ), 'scanned_at' => time(), 'scanned_by' => get_current_user_id(), 'axe_version' => ACCESSVANE_AXE_VERSION, 'score' => $this->score( $issues ), 'counts' => $counts, 'elements' => $elements, 'passes' => absint( $request->get_param( 'passes' ) ), 'incomplete' => absint( $request->get_param( 'incomplete' ) ), 'issues' => $issues, ); AccessVane_Storage::save( $result ); return rest_ensure_response( array( 'saved' => true, 'result' => $result, 'summary' => AccessVane_Storage::summary(), ) ); } /** * Forget one stored result. * * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response */ public function delete_result( WP_REST_Request $request ) { AccessVane_Storage::forget( esc_url_raw( $request->get_param( 'url' ) ) ); return rest_ensure_response( array( 'deleted' => true, 'summary' => AccessVane_Storage::summary(), ) ); } /** * Every stored result plus the site roll-up. * * @return WP_REST_Response */ public function list_results() { return rest_ensure_response( array( 'results' => array_values( AccessVane_Storage::all() ), 'summary' => AccessVane_Storage::summary(), ) ); } /** * Save alt text for one attachment. * * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response|WP_Error */ public function save_alt_text( WP_REST_Request $request ) { $id = absint( $request->get_param( 'id' ) ); if ( ! $id || 'attachment' !== get_post_type( $id ) ) { return new WP_Error( 'accessvane_bad_attachment', __( 'That image could not be found.', 'accessvane' ), array( 'status' => 404 ) ); } if ( ! current_user_can( 'edit_post', $id ) ) { return new WP_Error( 'accessvane_forbidden', __( 'You are not allowed to edit that image.', 'accessvane' ), array( 'status' => 403 ) ); } $alt = sanitize_text_field( $request->get_param( 'alt' ) ); // wp_slash before update_post_meta: the value has already been // unslashed by the REST layer, and update_post_meta unslashes again. // Without this, alt text containing an apostrophe loses its backslash // handling and saves mangled. update_post_meta( $id, '_wp_attachment_image_alt', wp_slash( $alt ) ); return rest_ensure_response( array( 'saved' => true, 'id' => $id, 'alt' => $alt, ) ); } /** * Turn one raw axe violation into our stored shape, merging in the * catalogue's plain-English explanation. * * @param mixed $violation Raw violation from axe-core. * @return array|null */ private function normalise_violation( $violation ) { if ( ! is_array( $violation ) || empty( $violation['id'] ) ) { return null; } $rule_id = sanitize_key( $violation['id'] ); $tags = isset( $violation['tags'] ) && is_array( $violation['tags'] ) ? $violation['tags'] : array(); $parsed = $this->parse_tags( $tags ); $catalog = AccessVane_Catalog::issue( $rule_id ); $nodes = isset( $violation['nodes'] ) && is_array( $violation['nodes'] ) ? $violation['nodes'] : array(); $examples = array(); foreach ( array_slice( $nodes, 0, 5 ) as $node ) { $examples[] = array( 'selector' => isset( $node['target'] ) ? sanitize_text_field( $this->flatten_target( $node['target'] ) ) : '', 'html' => isset( $node['html'] ) ? $this->trim_html( $node['html'] ) : '', ); } $impact = isset( $violation['impact'] ) ? sanitize_key( $violation['impact'] ) : 'moderate'; if ( ! in_array( $impact, array( 'critical', 'serious', 'moderate', 'minor' ), true ) ) { $impact = 'moderate'; } return array( 'id' => $rule_id, 'impact' => $impact, 'wcag' => $parsed['wcag'], 'level' => $parsed['level'], 'bestPracticeOnly' => $parsed['best_practice_only'], 'title' => $catalog ? $catalog['title'] : sanitize_text_field( isset( $violation['help'] ) ? $violation['help'] : $rule_id ), 'plain' => $catalog ? $catalog['plain'] : sanitize_text_field( isset( $violation['description'] ) ? $violation['description'] : '' ), 'who' => $catalog ? $catalog['who'] : '', 'fix' => $catalog ? $catalog['fix'] : '', 'wpFix' => $catalog ? $catalog['wpFix'] : '', 'autoFixable' => $catalog ? ! empty( $catalog['autoFixable'] ) : false, 'count' => count( $nodes ), 'examples' => $examples, ); } /** * Read WCAG criteria and conformance level out of axe's tag list. * * Tags look like wcag2a, wcag21aa, wcag143 and best-practice. The numeric * ones encode the success criterion, so wcag143 means 1.4.3. * * @param array $tags Tag list. * @return array */ private function parse_tags( array $tags ) { $criteria = array(); $level = ''; foreach ( $tags as $tag ) { $tag = (string) $tag; if ( preg_match( '/^wcag(\d)(\d)(\d+)$/', $tag, $matches ) ) { $criteria[] = $matches[1] . '.' . $matches[2] . '.' . $matches[3]; continue; } if ( preg_match( '/^wcag\d*(a{1,3})$/', $tag, $matches ) ) { $found = strtoupper( $matches[1] ); if ( '' === $level || strlen( $found ) < strlen( $level ) ) { $level = $found; } } } $criteria = array_values( array_unique( $criteria ) ); sort( $criteria ); return array( 'wcag' => $criteria, 'level' => $level, 'best_practice_only' => empty( $criteria ) && in_array( 'best-practice', $tags, true ), ); } /** * axe targets can be nested arrays when an element sits inside a frame. * * @param mixed $target Target value. * @return string */ private function flatten_target( $target ) { if ( is_string( $target ) ) { return $target; } if ( ! is_array( $target ) ) { return ''; } $parts = array(); array_walk_recursive( $target, static function ( $item ) use ( &$parts ) { $parts[] = (string) $item; } ); return implode( ' ', $parts ); } /** * Shorten and neutralise a markup sample for display. * * The value is stored and later printed inside a block. It is escaped * again at render time, but truncating here keeps the option small. * * @param string $html Raw markup. * @return string */ private function trim_html( $html ) { $html = preg_replace( '/\s+/', ' ', (string) $html ); $html = trim( $html ); if ( function_exists( 'mb_strlen' ) && mb_strlen( $html ) > 220 ) { return mb_substr( $html, 0, 220 ) . '...'; } if ( strlen( $html ) > 220 ) { return substr( $html, 0, 220 ) . '...'; } return $html; } /** * Score a page out of 100. * * Deliberately identical to the public scanner's formula so the same page * cannot score differently in the two places. Each distinct rule costs its * impact weight, scaled logarithmically by how widespread it is — one broken * template repeated 300 times should hurt, but not swamp everything else. * * @param array $issues Normalised issues. * @return int */ private function score( array $issues ) { $weights = array( 'critical' => 10, 'serious' => 6, 'moderate' => 3, 'minor' => 1, ); $penalty = 0.0; foreach ( $issues as $issue ) { if ( ! empty( $issue['bestPracticeOnly'] ) ) { continue; } $weight = isset( $weights[ $issue['impact'] ] ) ? $weights[ $issue['impact'] ] : 3; $count = max( 1, (int) $issue['count'] ); $penalty += $weight * ( 1 + log10( $count ) ); } return (int) max( 0, min( 100, round( 100 - $penalty ) ) ); } }