get( 'local_fonts' ) && ( new \GoConsent\Terms() )->accepted(); } /** * Background download hook. Visitors never wait on a download. */ public static function register() { add_action( self::HOOK, array( __CLASS__, 'run_queue' ) ); } /** * Map of stylesheet hash => { url, src, time } or { failed, reason, done, total, src }. * * @return array */ private static function map() { $m = get_option( self::OPTION, array() ); return is_array( $m ) ? $m : array(); } /** * Normalise a stylesheet URL and hash it. Cache-busting "ver" values are dropped: * Google ignores them, and themes that change them per page would never match. * * @param string $href URL. * @return array{0:string,1:string} */ private static function key( $href ) { $href = html_entity_decode( trim( $href ), ENT_QUOTES | ENT_HTML5 ); if ( 0 === strpos( $href, '//' ) ) { $href = 'https:' . $href; } $href = (string) preg_replace( '#(?<=[?&])ver=[^&]*(?:&|$)#', '', $href ); $href = rtrim( $href, '?&' ); return array( md5( $href ), $href ); } /** * Local URL if this stylesheet is already on the site. * * @param string $href URL. * @return string */ public static function cached( $href ) { list( $key ) = self::key( $href ); $map = self::map(); return empty( $map[ $key ]['url'] ) ? '' : $map[ $key ]['url']; } /** * Queue a stylesheet for the background download. * * @param string $href URL. */ public static function queue( $href ) { if ( ! self::enabled() ) { return; } list( $key, $href ) = self::key( $href ); $q = get_option( self::QUEUE, array() ); $q = is_array( $q ) ? $q : array(); if ( isset( $q[ $key ] ) ) { return; } $map = self::map(); if ( ! empty( $map[ $key ]['failed'] ) && $map[ $key ]['failed'] > time() - HOUR_IN_SECONDS && empty( $map[ $key ]['done'] ) ) { return; // Failed outright recently; the next scan retries. } $q[ $key ] = $href; update_option( self::QUEUE, array_slice( $q, -20, null, true ), false ); if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + 5, self::HOOK ); } } /** * Cron: work through the queue for up to 20 seconds, then reschedule. */ public static function run_queue() { if ( ! self::enabled() ) { delete_option( self::QUEUE ); return; } $q = get_option( self::QUEUE, array() ); if ( ! is_array( $q ) || ! $q ) { return; } $until = microtime( true ) + 20; foreach ( $q as $key => $href ) { if ( microtime( true ) > $until ) { break; } $r = self::localize( $href, (int) ( $until - microtime( true ) ) ); $m = self::map(); if ( $r || empty( $m[ $key ]['done'] ) ) { unset( $q[ $key ] ); // Done, or failed before any file arrived (retried by the next scan). } } update_option( self::QUEUE, $q, false ); if ( $q ) { wp_schedule_single_event( time() + 30, self::HOOK ); } } /** * Font files for this stylesheet still being copied, or why the last try failed. * * @return array[] [ src, reason, done, total ] per stylesheet not yet on the site. */ public static function problems() { $out = array(); foreach ( self::map() as $row ) { if ( empty( $row['url'] ) && ! empty( $row['failed'] ) ) { $out[] = array( 'src' => isset( $row['src'] ) ? $row['src'] : '', 'reason' => isset( $row['reason'] ) ? $row['reason'] : '', 'done' => isset( $row['done'] ) ? (int) $row['done'] : 0, 'total' => isset( $row['total'] ) ? (int) $row['total'] : 0, ); } } return $out; } /** * Font family names in a Google Fonts URL. * * @param string $href URL. * @return string */ public static function families( $href ) { $q = (string) wp_parse_url( html_entity_decode( $href, ENT_QUOTES | ENT_HTML5 ), PHP_URL_QUERY ); preg_match_all( '#(?:^|&)family=([^&]+)#', $q, $m ); $names = array(); foreach ( $m[1] as $f ) { foreach ( explode( '|', rawurldecode( str_replace( '+', ' ', $f ) ) ) as $one ) { $names[] = trim( strtok( $one, ':' ) ); } } return implode( ', ', array_unique( array_filter( $names ) ) ); } /** * Download a stylesheet and its font files. Already downloaded files are kept, * so a run that runs out of time resumes where it stopped. * * @param string $href Google Fonts stylesheet URL. * @param int $budget Seconds available. * @return string Local CSS URL, or '' when not (yet) complete. */ public static function localize( $href, $budget = 20 ) { if ( ! self::enabled() ) { return ''; } list( $key, $href ) = self::key( $href ); $map = self::map(); if ( ! empty( $map[ $key ]['url'] ) ) { return $map[ $key ]['url']; } if ( get_transient( 'goconsent_font_lock_' . $key ) ) { return ''; } set_transient( 'goconsent_font_lock_' . $key, 1, MINUTE_IN_SECONDS ); $until = microtime( true ) + max( 5, (int) $budget ); $uploads = wp_upload_dir(); $dir = trailingslashit( $uploads['basedir'] ) . 'goconsent-fonts/' . $key; $url = trailingslashit( $uploads['baseurl'] ) . 'goconsent-fonts/' . $key; if ( ! wp_mkdir_p( $dir ) ) { return self::fail( $key, $href, __( 'The uploads folder isn’t writable, so the fonts can’t be stored on the site.', 'goconsent' ) ); } $css = is_readable( $dir . '/source.css' ) ? (string) file_get_contents( $dir . '/source.css' ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file. if ( false === strpos( $css, '@font-face' ) ) { $got = self::get_css( $href ); if ( '' === $got['css'] ) { return self::fail( $key, $href, $got['reason'] ); } $css = $got['css']; file_put_contents( $dir . '/source.css', $css ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing to uploads. } preg_match_all( '#url\((["\']?)(https://fonts\.gstatic\.com/[^)"\']+)\1\)#i', $css, $m ); $files = array(); foreach ( array_unique( $m[2] ) as $remote ) { $files[ $remote ] = substr( md5( $remote ), 0, 12 ) . '-' . sanitize_file_name( basename( (string) wp_parse_url( $remote, PHP_URL_PATH ) ) ); } $missing = array(); foreach ( $files as $remote => $name ) { if ( ! file_exists( $dir . '/' . $name ) ) { $missing[ $remote ] = $name; } } $errors = array(); foreach ( $missing as $remote => $name ) { if ( microtime( true ) > $until ) { break; } $body = self::fetch( $remote ); if ( '' !== $body ) { file_put_contents( $dir . '/' . $name, $body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing to uploads. } else { $errors[] = $remote; } } $done = 0; foreach ( $files as $name ) { $done += file_exists( $dir . '/' . $name ) ? 1 : 0; } if ( $done < count( $files ) ) { $reason = $errors ? __( 'Some font files didn’t download. GoConsent keeps the ones it has and tries the rest again.', 'goconsent' ) : __( 'Still copying: this font has many files. GoConsent continues in the background.', 'goconsent' ); $out = self::fail( $key, $href, $reason, $done, count( $files ) ); if ( ! $errors ) { self::queue( $href ); } return $out; } $local = str_replace( array_keys( $files ), array_map( static function ( $n ) use ( $url ) { return $url . '/' . $n; }, array_values( $files ) ), $css ); if ( false !== stripos( $local, 'fonts.gstatic.com' ) ) { return self::fail( $key, $href, __( 'The font stylesheet uses a format GoConsent can’t copy.', 'goconsent' ) ); } file_put_contents( $dir . '/fonts.css', $local ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing to uploads. $map = self::map(); $map[ $key ] = array( 'url' => $url . '/fonts.css', 'src' => $href, 'time' => time(), ); update_option( self::OPTION, $map, false ); delete_transient( 'goconsent_font_lock_' . $key ); return $map[ $key ]['url']; } /** * Fetch the Google Fonts stylesheet through the WordPress HTTP API. * * @param string $href URL. * @return array{css:string,reason:string} */ private static function get_css( $href ) { $res = wp_safe_remote_get( $href, array( 'timeout' => 10, 'user-agent' => self::UA, ) ); $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ); $css = is_wp_error( $res ) ? '' : (string) wp_remote_retrieve_body( $res ); if ( 200 === $code && false !== strpos( $css, '@font-face' ) ) { return array( 'css' => $css, 'reason' => '', ); } if ( is_wp_error( $res ) ) { return array( 'css' => '', /* translators: %s: error message. */ 'reason' => sprintf( __( 'The server couldn’t reach Google Fonts (%s). The host or another plugin may block outgoing connections.', 'goconsent' ), $res->get_error_message() ), ); } return array( 'css' => '', /* translators: %d: HTTP status. */ 'reason' => sprintf( __( 'Google Fonts answered with an error (HTTP %d). The font address on the site may be wrong.', 'goconsent' ), $code ), ); } /** * Download one font file through the WordPress HTTP API. * * @param string $url fonts.gstatic.com URL. * @return string File contents, or '' on failure. */ private static function fetch( $url ) { $res = wp_safe_remote_get( $url, array( 'timeout' => 15, 'user-agent' => self::UA, ) ); return ( ! is_wp_error( $res ) && 200 === (int) wp_remote_retrieve_response_code( $res ) ) ? (string) wp_remote_retrieve_body( $res ) : ''; } /** * Record a failure or partial progress. * * @param string $key Stylesheet hash. * @param string $href Stylesheet URL. * @param string $reason Plain-language reason. * @param int $done Files copied. * @param int $total Files in total. * @return string Always ''. */ private static function fail( $key, $href = '', $reason = '', $done = 0, $total = 0 ) { $map = self::map(); $map[ $key ] = array( 'failed' => time(), 'src' => $href, 'reason' => $reason, 'done' => $done, 'total' => $total, ); update_option( self::OPTION, $map, false ); delete_transient( 'goconsent_font_lock_' . $key ); return ''; } /** * Rewrite Google Fonts and @import references; drop preconnect hints to Google. * Stylesheets not yet on the site are queued and copied in the background. * * @param string $html HTML. * @return string */ public static function rewrite( $html ) { if ( ! self::enabled() ) { return $html; } if ( false === stripos( $html, 'fonts.googleapis.com' ) && false === stripos( $html, 'fonts.gstatic.com' ) ) { return $html; } // Visitors never wait on a download: unknown stylesheets are queued for the background job. $local = static function ( $href ) { $cached = self::cached( $href ); if ( '' === $cached ) { self::queue( $href ); } return $cached; }; $out = preg_replace_callback( '#]*>#i', static function ( $m ) use ( $local ) { $tag = $m[0]; if ( preg_match( '#\brel\s*=\s*["\']?(preconnect|dns-prefetch)#i', $tag ) && preg_match( '#fonts\.(googleapis|gstatic)\.com#i', $tag ) ) { return ''; } if ( ! preg_match( '#\bhref\s*=\s*(["\'])((?:https?:)?//fonts\.googleapis\.com/css[^"\']*)\1#i', $tag, $h ) ) { return $tag; } $new = $local( $h[2] ); return $new ? str_replace( $h[0], 'href=' . $h[1] . esc_url( $new ) . $h[1], $tag ) : $tag; }, $html ); if ( null === $out ) { return $html; } $out2 = preg_replace_callback( '#@import\s+(?:url\()?\s*(["\']?)((?:https?:)?//fonts\.googleapis\.com/css[^"\')\s]*)\1\s*\)?\s*;#i', static function ( $m ) use ( $local ) { $new = $local( $m[2] ); return $new ? '@import url("' . esc_url( $new ) . '");' : $m[0]; }, $out ); return null === $out2 ? $out : $out2; } /** * Delete downloaded fonts (uninstall). */ public static function purge() { $uploads = wp_upload_dir(); $base = trailingslashit( $uploads['basedir'] ) . 'goconsent-fonts'; if ( ! is_dir( $base ) ) { return; } $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $base, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $it as $f ) { $f->isDir() ? rmdir( $f->getPathname() ) : wp_delete_file( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } rmdir( $base ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } }