409 ) ); } Scans::update( $active['id'], array( 'status' => 'cancelled', 'finished_at' => gmdate( 'Y-m-d H:i:s' ), ) ); } $config = array_merge( Settings::all(), $overrides ); $urls = Discovery::urls( $config ); if ( ! $urls ) { return new \WP_Error( 'a11ymate_no_urls', __( 'No pages were found to scan.', 'a11ymate' ), array( 'status' => 400 ) ); } $snapshot = array( 'max_pages' => (int) $config['max_pages'], 'post_types' => $config['post_types'], 'target' => $config['wcag_target'], 'categories' => $config['categories'], ); $id = Scans::create( $source, $urls, $with_rendered, $snapshot ); return $id; } /** * Run the server pass over the next few pages. * * @param int $scan_id Scan. * @param int $batch Pages per call. * @param int $budget Seconds to spend at most. * @return array Progress. */ public static function step( $scan_id, $batch = 3, $budget = 20 ) { $scan = Scans::get( $scan_id ); if ( ! $scan || 'running' !== $scan['status'] ) { return self::progress( $scan_id ); } $started = microtime( true ); $done = 0; foreach ( Scans::pending_pages( $scan_id, 'server', $batch ) as $page ) { self::scan_page( $scan, $page ); ++$done; if ( ( microtime( true ) - $started ) > $budget ) { break; } } Issues::refresh_counts( $scan_id ); $progress = self::progress( $scan_id ); if ( $progress['server_pending'] < 1 && ! $scan['with_rendered'] ) { self::finish( $scan_id ); $progress = self::progress( $scan_id ); } return $progress; } /** * Fetch and analyse one page. * * @param array $scan Scan. * @param array $page Page row. * @return void */ private static function scan_page( array $scan, array $page ) { $res = Fetcher::fetch( $page['url'] ); $data = array( 'server_done' => 1, 'http_code' => $res['code'], ); $ok = $res['code'] >= 200 && $res['code'] < 400; if ( '404' === $page['kind'] && 404 === $res['code'] && '' !== $res['html'] ) { $ok = true; } if ( ! $ok || '' === $res['html'] ) { $data['error'] = '' !== $res['error'] ? $res['error'] : sprintf( /* translators: %d: HTTP status */ __( 'The page returned HTTP %d.', 'a11ymate' ), $res['code'] ); $data['rendered_done'] = 1; Scans::update_page( $page['id'], $data ); Scans::recount( $scan['id'] ); return; } $out = ServerScan::analyse( $res['html'], $page['url'] ); $data['title'] = $out['title']; Issues::record( $scan['id'], $page['id'], $page['url'], $out['findings'], 'all' ); $data['issue_count'] = count( $out['findings'] ); Scans::update_page( $page['id'], $data ); Scans::recount( $scan['id'] ); } /** * Save what the browser found on one page. * * @param int $scan_id Scan. * @param int $page_id Page. * @param array $findings Findings, each with a viewport key. * @param array $meta Studio samples. * @param bool $failed Whether the browser could not examine the page. * @return array|\WP_Error */ public static function save_rendered( $scan_id, $page_id, array $findings, array $meta, $failed = false ) { $page = Scans::page( $page_id ); if ( ! $page || (int) $page['scan_id'] !== (int) $scan_id ) { return new \WP_Error( 'a11ymate_bad_page', __( 'That page is not part of this scan.', 'a11ymate' ), array( 'status' => 404 ) ); } if ( $failed ) { Scans::update_page( $page_id, array( 'rendered_done' => 1, 'error' => __( 'The browser could not examine this page (it may stop being shown in a frame).', 'a11ymate' ), ) ); Scans::recount( $scan_id ); return self::progress( $scan_id ); } $enabled = array_flip( Registry::enabled_ids( 'rendered' ) ); $by_vp = array(); foreach ( $findings as $f ) { if ( empty( $f['rule'] ) || ! isset( $enabled[ $f['rule'] ] ) ) { continue; } $vp = isset( $f['viewport'] ) && in_array( $f['viewport'], array( 'desktop', 'mobile' ), true ) ? $f['viewport'] : 'all'; $by_vp[ $vp ][] = array( 'rule' => sanitize_key( $f['rule'] ), 'selector' => isset( $f['selector'] ) ? self::clean_code( $f['selector'] ) : '', 'snippet' => isset( $f['snippet'] ) ? self::clean_code( $f['snippet'] ) : '', 'detail' => isset( $f['detail'] ) && is_array( $f['detail'] ) ? $f['detail'] : array(), ); } $stored = 0; foreach ( $by_vp as $vp => $list ) { $stored += Issues::record( $scan_id, $page_id, $page['url'], $list, $vp ); } Scans::update_page( $page_id, array( 'rendered_done' => 1, 'issue_count' => (int) $page['issue_count'] + $stored, ) ); Scans::recount( $scan_id ); Issues::refresh_counts( $scan_id ); return self::progress( $scan_id ); } /** * Keep markup-looking text intact (it is data, shown with textContent), but strip control characters. * * @param mixed $value Value. * @return string */ private static function clean_code( $value ) { $value = wp_check_invalid_utf8( (string) $value ); return substr( preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $value ), 0, 600 ); } /** * Progress for the UI. * * @param int $scan_id Scan. * @return array */ public static function progress( $scan_id ) { $scan = Scans::get( $scan_id ); if ( ! $scan ) { return array(); } $counts = Scans::recount( $scan_id ); $scan = Scans::get( $scan_id ); $total = max( 1, $counts['total'] ); $steps = $scan['with_rendered'] ? 2 : 1; $done = $counts['server_done'] + ( $scan['with_rendered'] ? $counts['rendered_done'] : 0 ); $open = Issues::counts(); return array( 'id' => $scan['id'], 'status' => $scan['status'], 'source' => $scan['source'], 'with_rendered' => $scan['with_rendered'], 'total' => $counts['total'], 'server_done' => $counts['server_done'], 'server_pending' => $counts['total'] - $counts['server_done'], 'rendered_done' => $counts['rendered_done'], 'rendered_pending' => $scan['with_rendered'] ? $counts['total'] - $counts['rendered_done'] : 0, 'errors' => $counts['errors'], 'percent' => (int) round( 100 * $done / ( $total * $steps ) ), 'issues' => $open, 'score' => $scan['score'], ); } /** * Finish a scan: scores, fixed issues, summary, hooks. * * @param int $scan_id Scan. * @return array|null Summary. */ public static function finish( $scan_id ) { global $wpdb; $scan = Scans::get( $scan_id ); if ( ! $scan || 'complete' === $scan['status'] ) { return $scan ? $scan['summary'] : null; } $prev = Scans::latest_complete(); $passes = array( 'server' ); if ( $scan['with_rendered'] ) { $passes[] = 'rendered'; } self::duplicate_titles( $scan_id ); Issues::refresh_counts( $scan_id ); $max_pages = isset( $scan['config']['max_pages'] ) ? (int) $scan['config']['max_pages'] : 0; $full = $max_pages > 0 && (int) $scan['total_pages'] < $max_pages; $fixed = Issues::mark_fixed( $scan_id, $passes, Registry::enabled_ids(), $full ); // Page scores. $issues = \A11yMate\Schema::table( 'issues' ); $occ = \A11yMate\Schema::table( 'occurrences' ); $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT o.page_id, i.severity, COUNT(*) AS n FROM %i o JOIN %i i ON i.id = o.issue_id WHERE o.scan_id = %d AND i.mode = 'automated' AND i.state = 'open' GROUP BY o.page_id, i.rule_id, i.severity", $occ, $issues, $scan_id ), ARRAY_A ); $per_page = array(); foreach ( $rows as $r ) { $per_page[ $r['page_id'] ][] = array( 'severity' => $r['severity'], 'n' => (int) $r['n'], ); } foreach ( Scans::pages( $scan_id ) as $p ) { if ( ! empty( $p['error'] ) ) { continue; } $entries = isset( $per_page[ $p['id'] ] ) ? $per_page[ $p['id'] ] : array(); Scans::update_page( $p['id'], array( 'score' => Score::page( $entries ) ) ); } $site = Score::site(); $ok_pages = 0; foreach ( Scans::pages( $scan_id ) as $p ) { if ( empty( $p['error'] ) ) { ++$ok_pages; } } if ( 0 === $ok_pages ) { Scans::update( $scan_id, array( 'status' => 'failed', 'finished_at' => gmdate( 'Y-m-d H:i:s' ), 'summary' => wp_json_encode( array( 'error' => __( 'None of the pages could be fetched. A11yMate loads your pages from the server itself; check the site can reach its own address, or add credentials with the a11ymate_fetch_args filter if the site is behind a login.', 'a11ymate' ) ) ), ) ); return null; } $new = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE first_scan = %d', $issues, $scan_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $counts = Issues::counts(); $pages = Scans::pages( $scan_id ); $errors = 0; foreach ( $pages as $p ) { if ( ! empty( $p['error'] ) ) { ++$errors; } } $summary = array( 'score' => $site['score'], 'previous_score' => $prev ? $prev['score'] : null, 'categories' => $site['categories'], 'severity' => $counts['severity'], 'review' => $counts['review'], 'open' => $counts['open'], 'new' => $new, 'fixed' => $fixed, 'pages' => count( $pages ), 'page_errors' => $errors, 'rendered' => (bool) $scan['with_rendered'], ); Scans::update( $scan_id, array( 'status' => 'complete', 'finished_at' => gmdate( 'Y-m-d H:i:s' ), 'score' => $site['score'], 'summary' => wp_json_encode( $summary ), ) ); /** * Fires when a scan has finished. * * @param int $scan_id Scan id. * @param array $summary Summary: score, severity counts, new, fixed... */ do_action( 'a11ymate_scan_complete', $scan_id, $summary ); return $summary; } /** * Cross-page check: pages that share a title. * * @param int $scan_id Scan. * @return void */ private static function duplicate_titles( $scan_id ) { global $wpdb; if ( ! in_array( 'title-duplicate', Registry::enabled_ids( 'server' ), true ) ) { return; } $t = \A11yMate\Schema::table( 'pages' ); $dupes = $wpdb->get_col( $wpdb->prepare( "SELECT title FROM %i WHERE scan_id = %d AND title IS NOT NULL AND title <> '' AND error IS NULL AND kind <> '404' GROUP BY title HAVING COUNT(*) > 1", $t, $scan_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery foreach ( (array) $dupes as $title ) { $pages = $wpdb->get_results( $wpdb->prepare( "SELECT id, url FROM %i WHERE scan_id = %d AND title = %s AND kind <> '404'", $t, $scan_id, $title ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery foreach ( $pages as $p ) { Issues::record( $scan_id, $p['id'], $p['url'], array( array( 'rule' => 'title-duplicate', 'selector' => 'html > head > title', 'snippet' => '