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
}
}