$max ? mb_substr( $text, 0, $max ) : $text; } /** * An http(s) URL, or '' when the value is not one. * * @param mixed $value Untrusted value. */ function waysill_clean_url( $value ): string { if ( ! is_string( $value ) || strlen( $value ) > 2000 || ! preg_match( '#^https?://#i', $value ) ) { return ''; } return esc_url_raw( $value, array( 'http', 'https' ) ); } /** * A non-negative number, or null. * * @param mixed $value Untrusted value. */ function waysill_clean_number( $value ): ?float { return ( is_int( $value ) || is_float( $value ) ) && is_finite( (float) $value ) && $value >= 0 ? round( (float) $value, 2 ) : null; } /** * Up to `$max` items of a list, each passed through `$clean`; items it returns null for are dropped. * * @param mixed $list Untrusted value. * @param int $max Most items kept. * @param callable $clean Item sanitiser. * @return array */ function waysill_clean_list( $list, int $max, callable $clean ): array { $out = array(); if ( ! is_array( $list ) ) { return $out; } foreach ( $list as $item ) { if ( count( $out ) >= $max ) { break; } $clean_item = $clean( $item ); if ( null !== $clean_item ) { $out[] = $clean_item; } } return $out; } /** * One WCAG criterion of an issue, or null. * * @param mixed $raw Untrusted value. */ function waysill_sanitize_criterion( $raw ): ?array { if ( ! is_array( $raw ) || ! isset( $raw['sc'] ) || ! is_string( $raw['sc'] ) || ! preg_match( '/^\d\.\d{1,2}\.\d{1,2}$/', $raw['sc'] ) ) { return null; } return array( 'sc' => $raw['sc'], 'name' => waysill_clean_text( $raw['name'] ?? '', 200 ), 'level' => isset( $raw['level'] ) && in_array( $raw['level'], array( 'A', 'AA', 'AAA' ), true ) ? $raw['level'] : 'A', 'url' => waysill_clean_url( $raw['url'] ?? '' ), ); } /** * Colour details of a contrast issue, or null. * * @param mixed $raw Untrusted value. */ function waysill_sanitize_contrast( $raw ): ?array { if ( ! is_array( $raw ) ) { return null; } $hex = '/^#[0-9a-f]{6}$/i'; if ( ! isset( $raw['foreground'], $raw['background'] ) || ! is_string( $raw['foreground'] ) || ! is_string( $raw['background'] ) || ! preg_match( $hex, $raw['foreground'] ) || ! preg_match( $hex, $raw['background'] ) ) { return null; } $ratio = waysill_clean_number( $raw['ratio'] ?? null ); $required = waysill_clean_number( $raw['required'] ?? null ); if ( null === $ratio || null === $required ) { return null; } $suggestion = isset( $raw['suggestion'] ) && is_string( $raw['suggestion'] ) && preg_match( $hex, $raw['suggestion'] ) ? $raw['suggestion'] : null; $contrast = array( 'foreground' => strtolower( $raw['foreground'] ), 'background' => strtolower( $raw['background'] ), 'ratio' => $ratio, 'required' => $required, 'suggestion' => null === $suggestion ? null : strtolower( $suggestion ), ); foreach ( array( 'fontSize', 'fontWeight' ) as $field ) { if ( isset( $raw[ $field ] ) && is_string( $raw[ $field ] ) ) { $contrast[ $field ] = waysill_clean_text( $raw[ $field ], 50 ); } } return $contrast; } /** * The explanation of an issue (title, what is wrong, who is affected, fix steps, example). * * @param mixed $raw Untrusted value. */ function waysill_sanitize_rule_text( $raw ): array { $raw = is_array( $raw ) ? $raw : array(); $text = array( 'title' => waysill_clean_text( $raw['title'] ?? '', 300 ), 'whatIsWrong' => waysill_clean_text( $raw['whatIsWrong'] ?? '', 2000 ), 'whoIsAffected' => waysill_clean_text( $raw['whoIsAffected'] ?? '', 2000 ), 'fixSteps' => waysill_clean_list( $raw['fixSteps'] ?? array(), 10, static function ( $step ) { $step = waysill_clean_text( $step, 1000 ); return '' === $step ? null : $step; } ), ); if ( isset( $raw['example'] ) && is_array( $raw['example'] ) ) { $text['example'] = array( 'before' => waysill_clean_text( $raw['example']['before'] ?? '', 2000 ), 'after' => waysill_clean_text( $raw['example']['after'] ?? '', 2000 ), ); } return $text; } /** * One issue, or null when its identity (key, rule, impact) is missing or invalid. * * @param mixed $raw Untrusted value. */ function waysill_sanitize_issue( $raw ): ?array { if ( ! is_array( $raw ) ) { return null; } $key = $raw['key'] ?? null; $rule_id = $raw['ruleId'] ?? null; $impact = $raw['impact'] ?? null; if ( ! is_string( $key ) || ! preg_match( '/^[0-9a-f]{16}$/', $key ) || ! is_string( $rule_id ) || ! preg_match( '/^[a-z0-9-]{1,100}$/', $rule_id ) || ! in_array( $impact, waysill_impacts(), true ) ) { return null; } $issue = array( 'key' => $key, 'ruleId' => $rule_id, 'impact' => $impact, 'level' => isset( $raw['level'] ) && in_array( $raw['level'], array( 'A', 'AA', 'AAA', 'best-practice' ), true ) ? $raw['level'] : 'best-practice', 'criteria' => waysill_clean_list( $raw['criteria'] ?? array(), 10, 'waysill_sanitize_criterion' ), 'text' => waysill_sanitize_rule_text( $raw['text'] ?? array() ), 'selector' => waysill_clean_text( $raw['selector'] ?? '', 2000 ), 'snippet' => waysill_clean_text( $raw['snippet'] ?? '', WAYSILL_SNIPPET_MAX ), 'helpUrl' => waysill_clean_url( $raw['helpUrl'] ?? '' ), ); if ( isset( $raw['summary'] ) && is_string( $raw['summary'] ) ) { $issue['summary'] = waysill_clean_text( $raw['summary'], 2000 ); } $contrast = waysill_sanitize_contrast( $raw['contrast'] ?? null ); if ( null !== $contrast ) { $issue['contrast'] = $contrast; } return $issue; } /** * The heading and landmark outline of a checked page. * * @param mixed $raw Untrusted value. */ function waysill_sanitize_outline( $raw ): array { $raw = is_array( $raw ) ? $raw : array(); return array( 'headings' => waysill_clean_list( $raw['headings'] ?? array(), 300, static function ( $h ) { if ( ! is_array( $h ) || ! isset( $h['level'] ) || ! is_int( $h['level'] ) || $h['level'] < 1 || $h['level'] > 9 ) { return null; } return array( 'level' => $h['level'], 'text' => waysill_clean_text( $h['text'] ?? '', 200 ), 'selector' => waysill_clean_text( $h['selector'] ?? '', 2000 ), 'skipped' => ! empty( $h['skipped'] ), ); } ), 'landmarks' => waysill_clean_list( $raw['landmarks'] ?? array(), 100, static function ( $l ) { if ( ! is_array( $l ) || ! isset( $l['role'] ) || ! is_string( $l['role'] ) || ! preg_match( '/^[a-z]{1,30}$/', $l['role'] ) ) { return null; } return array( 'role' => $l['role'], 'label' => waysill_clean_text( $l['label'] ?? '', 200 ), 'selector' => waysill_clean_text( $l['selector'] ?? '', 2000 ), ); } ), 'problems' => waysill_clean_list( $raw['problems'] ?? array(), 50, static function ( $p ) { $p = waysill_clean_text( $p, 500 ); return '' === $p ? null : $p; } ), ); } /** * A complete, valid result built from what the browser sent, or null when it is not a result at all. * * Counts are always recomputed from the kept issues; the sent counts are ignored. * * @param mixed $raw Decoded JSON of a core `PageResult`. */ function waysill_sanitize_result( $raw ): ?array { if ( ! is_array( $raw ) || ! isset( $raw['issues'] ) || ! is_array( $raw['issues'] ) ) { return null; } $url = waysill_clean_url( $raw['url'] ?? '' ); if ( '' === $url ) { return null; } $settings = isset( $raw['settings'] ) && is_array( $raw['settings'] ) ? $raw['settings'] : array(); $checked_at = isset( $raw['checkedAt'] ) && is_string( $raw['checkedAt'] ) && preg_match( '/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,3})?Z$/', $raw['checkedAt'] ) ? $raw['checkedAt'] : gmdate( 'Y-m-d\TH:i:s\Z' ); $result = array( 'url' => $url, 'title' => waysill_clean_text( $raw['title'] ?? '', 500 ), 'checkedAt' => $checked_at, 'settings' => array( 'level' => isset( $settings['level'] ) && in_array( $settings['level'], array( 'A', 'AA', 'AAA' ), true ) ? $settings['level'] : 'AA', 'bestPractice' => ! empty( $settings['bestPractice'] ), ), 'issues' => waysill_clean_list( $raw['issues'], WAYSILL_MAX_ISSUES, 'waysill_sanitize_issue' ), 'counts' => array(), 'untestedFrames' => waysill_clean_list( $raw['untestedFrames'] ?? array(), 50, static function ( $u ) { $u = waysill_clean_url( $u ); return '' === $u ? null : $u; } ), 'outline' => waysill_sanitize_outline( $raw['outline'] ?? array() ), ); $result['counts'] = waysill_counts( $result ); return $result; } /** * Issues per impact, every impact present. * * @param array $result A sanitised result. * @return array */ function waysill_counts( array $result ): array { $counts = array_fill_keys( waysill_impacts(), 0 ); $issues = isset( $result['issues'] ) && is_array( $result['issues'] ) ? $result['issues'] : array(); foreach ( $issues as $issue ) { if ( is_array( $issue ) && isset( $issue['impact'] ) && isset( $counts[ $issue['impact'] ] ) ) { ++$counts[ $issue['impact'] ]; } } return $counts; } /** * Stores a sanitised result and its counts with the post. * * @param int $post_id Post id. * @param array $result Result from waysill_sanitize_result(). */ function waysill_save_result( int $post_id, array $result ): void { // update_post_meta() unslashes its value, which would break the JSON's escapes. update_post_meta( $post_id, '_waysill_result', wp_slash( (string) wp_json_encode( $result ) ) ); update_post_meta( $post_id, '_waysill_counts', waysill_counts( $result ) ); do_action( 'waysill_result_saved', $post_id, $result ); } /** * The stored result of a post, or null when it has none. * * @param int $post_id Post id. */ function waysill_get_result( int $post_id ): ?array { $json = get_post_meta( $post_id, '_waysill_result', true ); if ( ! is_string( $json ) || '' === $json ) { return null; } $result = json_decode( $json, true ); return is_array( $result ) ? $result : null; } /** * Whether the current user may see and create results at all: administrators always, others when one of their roles * is chosen in the settings. Each post still needs `edit_post` on top of this. */ function waysill_user_can_see_results(): bool { if ( ! is_user_logged_in() ) { return false; } $can = current_user_can( 'manage_options' ); if ( ! $can ) { $roles = (array) wp_get_current_user()->roles; $can = array() !== array_intersect( $roles, waysill_settings()['roles'] ); } return (bool) apply_filters( 'waysill_user_can_see_results', $can ); } /** Deletes the stored results and counts of every post. */ function waysill_delete_all_results(): void { delete_post_meta_by_key( '_waysill_result' ); delete_post_meta_by_key( '_waysill_counts' ); do_action( 'waysill_results_deleted' ); }